diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..f94e5394 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,16 @@ +{ + "permissions": { + "allow": [ + "Bash(find:*)", + "Bash(bundle exec rspec:*)", + "Bash(mkdir:*)", + "Bash(rspec:*)", + "Bash(bundle install:*)", + "Bash(ruby:*)", + "Bash(gem which:*)", + "Bash(bundle:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..e8588f39 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,3 @@ +### Что? + +### Чтобы что? diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..4bd82459 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: CI + +on: + push: + branches: [ master, main ] + pull_request: + branches: [ master, main ] + +jobs: + rubocop: + runs-on: ubuntu-latest + + strategy: + matrix: + ruby-version: ['3.2'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - name: Install dependencies + run: bundle install + + - name: Run RuboCop + run: bundle exec rubocop + + rspec: + runs-on: ubuntu-latest + + strategy: + matrix: + ruby-version: ['3.2'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - name: Install dependencies + run: bundle install + + - name: Run RSpec tests + run: bundle exec rspec diff --git a/.rubocop.yml b/.rubocop.yml index abe1c659..cc32da4b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,29 +1 @@ inherit_from: .rubocop_todo.yml - -# Offense count: 1 -Security/Eval: - Exclude: - - 'bin/*' - -# Configuration parameters: AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. -# URISchemes: http, https -Metrics/LineLength: - Max: 120 - -Metrics/BlockLength: - ExcludedMethods: ['describe', 'context'] - -Style/AsciiComments: - Enabled: false - -Style/Copyright: - Notice: 'Copyright (\(c\) )?2[0-9]{3} .+' - AutocorrectNotice: "# Copyright (c) 2018 FINFEX https://github.com/finfex\n" - Description: 'Include a copyright notice in each file before any code.' - Enabled: true - VersionAdded: '0.1' - -AllCops: - Exclude: - - utils/* - - vendor/bundle/**/* diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 33c1993d..2796008f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,18 +1,284 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2018-12-18 21:28:41 +0300 using RuboCop version 0.61.1. +# on 2025-10-19 18:26:32 UTC using RuboCop version 1.81.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 9 +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation. +Gemspec/OrderedDependencies: + Exclude: + - 'payment_services.gemspec' + +# Offense count: 1 +# Configuration parameters: Severity. +Gemspec/RequiredRubyVersion: + Exclude: + - 'payment_services.gemspec' + +# Offense count: 6 +# This cop supports safe autocorrection (--autocorrect). +Layout/EmptyLineAfterGuardClause: + Exclude: + - 'lib/payment_services/block_io/client.rb' + - 'lib/payment_services/crypto_apis_v2/transaction_repository.rb' + - 'lib/payment_services/obmenka/invoicer.rb' + - 'lib/payment_services/tronscan/transaction.rb' + - 'lib/payment_services/your_payments/invoicer.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Layout/EmptyLines: + Exclude: + - 'spec/rails_helper.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowAliasSyntax, AllowedMethods. +# AllowedMethods: alias_method, public, protected, private +Layout/EmptyLinesAroundAttributeAccessor: + Exclude: + - 'lib/payment_services/paylama/payout_adapter.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: empty_lines, no_empty_lines +Layout/EmptyLinesAroundBlockBody: + Exclude: + - 'spec/payment_services/rbk/payout_destination_spec.rb' + - 'spec/payment_services/rbk/payout_spec.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: empty_lines, empty_lines_except_namespace, empty_lines_special, no_empty_lines, beginning_only, ending_only +Layout/EmptyLinesAroundClassBody: + Exclude: + - 'lib/payment_services/payeer/payout_adapter.rb' + +# Offense count: 12 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowForAlignment, AllowBeforeTrailingComments, ForceEqualSignAlignment. +Layout/ExtraSpacing: + Exclude: + - 'lib/payment_services/any_pay/invoice.rb' + - 'lib/payment_services/any_pay/payout.rb' + - 'lib/payment_services/binance/payout_adapter.rb' + - 'lib/payment_services/blockchair/client.rb' + - 'lib/payment_services/coin_payments_hub/invoice.rb' + - 'lib/payment_services/crypto_apis_v2/blockchain.rb' + - 'lib/payment_services/crypto_apis_v2/client.rb' + - 'lib/payment_services/master_processing/client.rb' + - 'lib/payment_services/one_crypto/invoice.rb' + - 'lib/payment_services/transfera/invoicer.rb' + +# Offense count: 13 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: consistent, consistent_relative_to_receiver, special_for_inner_method_call, special_for_inner_method_call_in_parentheses +Layout/FirstArgumentIndentation: + Exclude: + - 'lib/payment_services/any_pay/client.rb' + - 'lib/payment_services/best_api/client.rb' + - 'lib/payment_services/block_io/client.rb' + - 'lib/payment_services/crypto_apis/payout_clients/base_client.rb' + - 'lib/payment_services/crypto_apis/payout_clients/ethereum_client.rb' + - 'lib/payment_services/crypto_apis/payout_clients/omni_client.rb' + - 'lib/payment_services/crypto_apis_v2/client.rb' + - 'lib/payment_services/ex_pay/client.rb' + - 'lib/payment_services/one_crypto/client.rb' + - 'lib/payment_services/tronscan/client.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: IndentationWidth. +# SupportedStyles: special_inside_parentheses, consistent, align_braces +Layout/FirstHashElementIndentation: + EnforcedStyle: consistent + +# Offense count: 162 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle. +# SupportedHashRocketStyles: key, separator, table +# SupportedColonStyles: key, separator, table +# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit +Layout/HashAlignment: + Enabled: false + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: aligned, indented, indented_relative_to_receiver +Layout/MultilineMethodCallIndentation: + Exclude: + - 'lib/payment_services/crypto_apis_v2/client.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: aligned, indented +Layout/MultilineOperationIndentation: + Exclude: + - 'lib/payment_services/base/p2p_bank_resolver.rb' + +# Offense count: 7 +# This cop supports safe autocorrection (--autocorrect). +Layout/SpaceAfterComma: + Exclude: + - 'lib/payment_services/binance/invoicer.rb' + - 'lib/payment_services/crypto_apis/invoicer.rb' + - 'lib/payment_services/crypto_apis_v2/transaction_repository.rb' + - 'lib/payment_services/exmo/invoicer.rb' + - 'lib/payment_services/paylama_crypto/transaction.rb' + +# Offense count: 14 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowForAlignment, EnforcedStyleForExponentOperator, EnforcedStyleForRationalLiterals. +# SupportedStylesForExponentOperator: space, no_space +# SupportedStylesForRationalLiterals: space, no_space +Layout/SpaceAroundOperators: + Exclude: + - 'lib/payment_services/any_pay/invoice.rb' + - 'lib/payment_services/any_pay/payout.rb' + - 'lib/payment_services/base/p2p_bank_resolver.rb' + - 'lib/payment_services/blockchair/client.rb' + - 'lib/payment_services/bridgex/client.rb' + - 'lib/payment_services/coin_payments_hub/client.rb' + - 'lib/payment_services/coin_payments_hub/invoice.rb' + - 'lib/payment_services/crypto_apis_v2/blockchain.rb' + - 'lib/payment_services/crypto_apis_v2/client.rb' + - 'lib/payment_services/exmo/client.rb' + - 'lib/payment_services/master_processing/client.rb' + - 'lib/payment_services/payeer/client.rb' + - 'lib/payment_services/transfera/invoicer.rb' + - 'lib/payment_services/tronscan/transaction_matcher.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: space, compact, no_space +Layout/SpaceInsideParens: + Exclude: + - 'lib/payment_services/cryptomus/payout_adapter.rb' + +# Offense count: 18 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: final_newline, final_blank_line +Layout/TrailingEmptyLines: + Enabled: false + +# Offense count: 23 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowInHeredoc. +Layout/TrailingWhitespace: + Enabled: false + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Lint/DeprecatedOpenSSLConstant: + Exclude: + - 'lib/payment_services/merchant_alikassa/client.rb' + - 'lib/payment_services/merchant_alikassa_virtual/client.rb' + +# Offense count: 4 +Lint/FloatComparison: + Exclude: + - 'lib/payment_services/payeer/invoice.rb' + - 'lib/payment_services/tronscan/transaction_matcher.rb' + - 'lib/payment_services/x_pay_pro/invoicer.rb' + - 'lib/payment_services/x_pay_pro_virtual/invoicer.rb' + +# Offense count: 2 +Lint/IneffectiveAccessModifier: + Exclude: + - 'lib/payment_services/master_processing/response.rb' + +# Offense count: 35 +# Configuration parameters: AllowedParentClasses. +Lint/MissingSuper: + Enabled: false + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +Lint/RedundantStringCoercion: + Exclude: + - 'lib/payment_services/block_io/client.rb' + - 'lib/payment_services/erapay/invoicer.rb' + - 'lib/payment_services/fire_kassa/invoicer.rb' + - 'lib/payment_services/liquid/payout_adapter.rb' + +# Offense count: 2 +Lint/RescueException: + Exclude: + - 'lib/payment_services/block_io/client.rb' + +# Offense count: 1 +Lint/ShadowedException: + Exclude: + - 'lib/payment_services/block_io/client.rb' + +# Offense count: 112 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowUnusedKeywordArguments, IgnoreEmptyMethods, IgnoreNotImplementedMethods, NotImplementedExceptions. +# NotImplementedExceptions: NotImplementedError +Lint/UnusedMethodArgument: + Enabled: false + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: ContextCreatingMethods, MethodCreatingMethods. +Lint/UselessAccessModifier: + Exclude: + - 'lib/payment_services/manual_by_group/invoicer.rb' + - 'lib/payment_services/master_processing/response.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Lint/UselessAssignment: + Exclude: + - 'lib/payment_services/base/client.rb' + - 'spec/unit/base_client_spec.rb' + +# Offense count: 57 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. Metrics/AbcSize: - Max: 52 + Max: 77 + +# Offense count: 23 +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. +# AllowedMethods: refine +Metrics/BlockLength: + Max: 152 + +# Offense count: 4 +# Configuration parameters: CountComments, CountAsOne. +Metrics/ClassLength: + Max: 487 + +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/CyclomaticComplexity: + Max: 20 -# Configuration parameters: CountComments, ExcludedMethods. +# Offense count: 68 +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. Metrics/MethodLength: - Max: 25 + Max: 24 + +# Offense count: 1 +# Configuration parameters: CountKeywordArgs, MaxOptionalParameters. +Metrics/ParameterLists: + Max: 6 + +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/PerceivedComplexity: + Max: 20 # Offense count: 2 Naming/AccessorMethodName: @@ -20,11 +286,369 @@ Naming/AccessorMethodName: - 'lib/payment_services/qiwi/client.rb' - 'lib/payment_services/qiwi/importer.rb' -# Offense count: 40 +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyleForLeadingUnderscores. +# SupportedStylesForLeadingUnderscores: disallowed, required, optional +Naming/MemoizedInstanceVariableName: + Exclude: + - 'lib/payment_services/appex_money/payout_adapter.rb' + +# Offense count: 1 +# Configuration parameters: NamePrefix, ForbiddenPrefixes, AllowedMethods, MethodDefinitionMacros, UseSorbetSigs. +# NamePrefix: is_, has_, have_, does_ +# ForbiddenPrefixes: is_, has_, have_, does_ +# AllowedMethods: is_a? +# MethodDefinitionMacros: define_method, define_singleton_method +Naming/PredicatePrefix: + Exclude: + - 'spec/**/*' + - 'lib/payment_services/binance/payout.rb' + +# Offense count: 15 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: PreferredName. +Naming/RescuedExceptionsVariableName: + Exclude: + - 'lib/payment_services/adv_cash/client.rb' + - 'lib/payment_services/ali_kassa/client.rb' + - 'lib/payment_services/any_money/client.rb' + - 'lib/payment_services/appex_money/client.rb' + - 'lib/payment_services/base/client.rb' + - 'lib/payment_services/block_io/client.rb' + - 'lib/payment_services/crypto_apis/clients/base_client.rb' + - 'lib/payment_services/kuna/client.rb' + - 'lib/payment_services/liquid/client.rb' + - 'lib/payment_services/obmenka/client.rb' + - 'lib/payment_services/perfect_money/client.rb' + - 'lib/payment_services/qiwi/payment_order_support.rb' + - 'lib/payment_services/rbk/client.rb' + - 'spec/unit/base_client_spec.rb' + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, ProceduralMethods, FunctionalMethods, AllowedMethods, AllowedPatterns, AllowBracesOnProceduralOneLiners, BracesRequiredMethods. +# SupportedStyles: line_count_based, semantic, braces_for_chaining, always_braces +# ProceduralMethods: benchmark, bm, bmbm, create, each_with_object, measure, new, realtime, tap, with_object +# FunctionalMethods: let, let!, subject, watch +# AllowedMethods: lambda, proc, it +Style/BlockDelimiters: + Exclude: + - 'spec/payment_services/base/client_spec.rb' + - 'spec/payment_services/rbk/payout_destination_spec.rb' + - 'spec/payment_services/rbk/payout_spec.rb' + - 'spec/unit/base_client_spec.rb' + +# Offense count: 2 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: MinBranchesCount. +Style/CaseLikeIf: + Exclude: + - 'lib/payment_services/base/client.rb' + - 'spec/unit/base_client_spec.rb' + +# Offense count: 251 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle, EnforcedStyleForClasses, EnforcedStyleForModules. +# SupportedStyles: nested, compact +# SupportedStylesForClasses: ~, nested, compact +# SupportedStylesForModules: ~, nested, compact +Style/ClassAndModuleChildren: + Enabled: false + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Style/ColonMethodCall: + Exclude: + - 'lib/payment_services/merchant_alikassa/client.rb' + - 'lib/payment_services/merchant_alikassa_virtual/client.rb' + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Keywords, RequireColon. +# Keywords: TODO, FIXME, OPTIMIZE, HACK, REVIEW, NOTE +Style/CommentAnnotation: + Exclude: + - 'lib/blockchain_com_client.rb' + - 'lib/payment_services/rbk/customer.rb' + - 'lib/payment_services/rbk/invoicer.rb' + - 'lib/payment_services/rbk/payment_card.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, SingleLineConditionsOnly, IncludeTernaryExpressions. +# SupportedStyles: assign_to_condition, assign_inside_condition +Style/ConditionalAssignment: + Exclude: + - 'lib/payment_services/tronscan/client.rb' + +# Offense count: 315 +# Configuration parameters: AllowedConstants. Style/Documentation: Enabled: false -# Offense count: 24 -# Configuration parameters: MinBodyLength. -Style/ClassAndModuleChildren: +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: format, sprintf, percent +Style/FormatString: + Exclude: + - 'lib/payment_services/merchant_alikassa/payout_adapter.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/GlobalStdStream: + Exclude: + - 'lib/payment_services/auto_logger.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals. +Style/GuardClause: + Exclude: + - 'lib/payment_services/pay_for_u/invoicer.rb' + - 'lib/payment_services/pay_for_u_h2h/invoicer.rb' + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: AllowedReceivers. +# AllowedReceivers: Thread.current +Style/HashEachMethods: + Exclude: + - 'lib/payment_services/blockchair/transaction_matcher.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/KeywordParametersOrder: + Exclude: + - 'lib/payment_services/payeer/client.rb' + +# Offense count: 5 +Style/MissingRespondToMissing: + Exclude: + - 'lib/payment_services/blockchair/transaction.rb' + - 'lib/payment_services/blockchair/transaction_matcher.rb' + - 'lib/payment_services/crypto_apis_v2/transaction.rb' + - 'lib/payment_services/crypto_apis_v2/transaction_repository.rb' + - 'lib/payment_services/paylama_crypto/transaction.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Style/MultilineIfModifier: + Exclude: + - 'lib/payment_services/crypto_apis/invoicer.rb' + - 'lib/payment_services/crypto_apis/payout_adapter.rb' + +# Offense count: 10 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowMethodComparison, ComparisonsThreshold. +Style/MultipleComparison: + Exclude: + - 'lib/payment_services/any_money/payout.rb' + - 'lib/payment_services/binance/payout.rb' + - 'lib/payment_services/exmo/invoice.rb' + - 'lib/payment_services/ff/transaction.rb' + - 'lib/payment_services/kuna/payout.rb' + - 'lib/payment_services/master_processing/invoice.rb' + - 'lib/payment_services/master_processing/payout.rb' + - 'lib/payment_services/obmenka/invoice.rb' + - 'lib/payment_services/obmenka/payout.rb' + - 'lib/payment_services/paylama_crypto/transaction.rb' + +# Offense count: 28 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: literals, strict +Style/MutableConstant: + Enabled: false + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: both, prefix, postfix +Style/NegatedIf: + Exclude: + - 'lib/payment_services/bridgex/invoicer.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Strict, AllowedNumbers, AllowedPatterns. +Style/NumericLiterals: + MinDigits: 10 + +# Offense count: 2 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle, AllowedMethods, AllowedPatterns. +# SupportedStyles: predicate, comparison +Style/NumericPredicate: + Exclude: + - 'spec/**/*' + - 'lib/payment_services/crypto_apis_v2/transaction.rb' + - 'lib/payment_services/paylama_crypto/transaction.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/ParallelAssignment: + Exclude: + - 'lib/payment_services/pay_for_u_h2h/invoicer.rb' + +# Offense count: 19 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: PreferredDelimiters. +Style/PercentLiteralDelimiters: Enabled: false + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Style/RedundantAssignment: + Exclude: + - 'lib/payment_services/ali_kassa/invoicer.rb' + - 'spec/unit/base_client_spec.rb' + +# Offense count: 4 +# This cop supports safe autocorrection (--autocorrect). +Style/RedundantBegin: + Exclude: + - 'lib/payment_services/appex_money/payout_adapter.rb' + - 'lib/payment_services/exmo/payout_adapter.rb' + - 'lib/payment_services/kuna/payout_adapter.rb' + - 'lib/payment_services/perfect_money/payout_adapter.rb' + +# Offense count: 3 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/RedundantInterpolation: + Exclude: + - 'lib/payment_services/merchant_alikassa/client.rb' + - 'lib/payment_services/merchant_alikassa_virtual/client.rb' + - 'lib/payment_services/panda_pay/client.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/RedundantParentheses: + Exclude: + - 'lib/payment_services/rbk/customer.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +Style/RescueModifier: + Exclude: + - 'lib/payment_services/rbk/invoice.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle. +# SupportedStyles: implicit, explicit +Style/RescueStandardError: + Exclude: + - 'lib/payment_services/perfect_money/client.rb' + - 'lib/payment_services/rbk/invoice.rb' + +# Offense count: 6 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength. +# AllowedMethods: present?, blank?, presence, try, try! +Style/SafeNavigation: + Exclude: + - 'lib/payment_services/crypto_apis/invoicer.rb' + - 'lib/payment_services/ex_pay/invoicer.rb' + - 'lib/payment_services/manual_by_group/invoicer.rb' + - 'lib/payment_services/master_processing/invoicer.rb' + - 'lib/payment_services/merchant_alikassa_virtual/invoicer.rb' + - 'lib/payment_services/obmenka/invoicer.rb' + +# Offense count: 7 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/SingleArgumentDig: + Exclude: + - 'lib/payment_services/any_money/payout_adapter.rb' + - 'lib/payment_services/any_pay/client.rb' + - 'lib/payment_services/appex_money/payout_adapter.rb' + - 'lib/payment_services/pay_for_u_h2h/invoicer.rb' + +# Offense count: 2 +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/SlicingWithRange: + Exclude: + - 'lib/payment_services/any_pay/payout_adapter.rb' + - 'lib/payment_services/merchant_alikassa/payout_adapter.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowModifier. +Style/SoleNestedConditional: + Exclude: + - 'lib/payment_services/cryptomus/invoicer.rb' + +# Offense count: 11 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: Mode. +Style/StringConcatenation: + Exclude: + - 'lib/payment_services/appex_money/client.rb' + - 'lib/payment_services/base.rb' + - 'lib/payment_services/kuna/client.rb' + - 'lib/payment_services/liquid/client.rb' + - 'lib/payment_services/payeer/client.rb' + +# Offense count: 116 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. +# SupportedStyles: single_quotes, double_quotes +Style/StringLiterals: + Exclude: + - 'lib/payment_services/any_money/invoicer.rb' + - 'lib/payment_services/any_pay/client.rb' + - 'lib/payment_services/coin_payments_hub/client.rb' + - 'lib/payment_services/exmo/client.rb' + - 'lib/payment_services/kuna/client.rb' + - 'lib/payment_services/liquid/client.rb' + - 'lib/payment_services/master_processing/client.rb' + - 'lib/payment_services/master_processing/invoicer.rb' + - 'lib/payment_services/merchant_alikassa/payout_adapter.rb' + - 'spec/support/schema.rb' + +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: . +# SupportedStyles: percent, brackets +Style/SymbolArray: + EnforcedStyle: percent + MinSize: 8 + +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: AllowMethodsWithArguments, AllowedMethods, AllowedPatterns, AllowComments. +# AllowedMethods: define_method +Style/SymbolProc: + Exclude: + - 'lib/payment_services/blockchair/transaction_matcher.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, AllowSafeAssignment. +# SupportedStyles: require_parentheses, require_no_parentheses, require_parentheses_when_complex +Style/TernaryParentheses: + Exclude: + - 'lib/payment_services/paylama/invoicer.rb' + +# Offense count: 2 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyleForMultiline. +# SupportedStylesForMultiline: comma, consistent_comma, diff_comma, no_comma +Style/TrailingCommaInHashLiteral: + Exclude: + - 'lib/payment_services/blockchair/blockchain.rb' + - 'lib/payment_services/bovapay/invoicer.rb' + +# Offense count: 1 +# This cop supports safe autocorrection (--autocorrect). +Style/UnlessElse: + Exclude: + - 'lib/payment_services/crypto_apis_v2/payout_adapter.rb' + +# Offense count: 88 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, IgnoreCopDirectives, AllowedPatterns, SplitStrings. +# URISchemes: http, https +Layout/LineLength: + Max: 199 diff --git a/.ruby-version b/.ruby-version index 59aa62c1..f092941a 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.4.5 +3.2.8 diff --git a/Gemfile.lock b/Gemfile.lock index 6e87d787..6bf1feba 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,85 +2,307 @@ PATH remote: . specs: payment_services (0.1.0) + activerecord activesupport + auto_logger (~> 0.1.4) + block_io jwt + money-rails virtus - workflow + workflow-activerecord GEM remote: https://rubygems.org/ specs: - activesupport (5.2.2) - concurrent-ruby (~> 1.0, >= 1.0.2) - i18n (>= 0.7, < 2) - minitest (~> 5.1) - tzinfo (~> 1.1) - ast (2.4.0) + actionpack (8.0.3) + actionview (= 8.0.3) + activesupport (= 8.0.3) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actionview (8.0.3) + activesupport (= 8.0.3) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activemodel (8.0.3) + activesupport (= 8.0.3) + activerecord (8.0.3) + activemodel (= 8.0.3) + activesupport (= 8.0.3) + timeout (>= 0.4.0) + activesupport (8.0.3) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + ast (2.4.3) + auto_logger (0.1.7) + activesupport + beautiful-log + awesome_print (1.8.0) axiom-types (0.1.1) descendants_tracker (~> 0.0.4) ice_nine (~> 0.11.0) thread_safe (~> 0.3, >= 0.3.1) + base32 (0.3.4) + base64 (0.3.0) + beautiful-log (0.2.2) + awesome_print (~> 1.8.0) + colorize (~> 0.8.1) + bech32 (1.5.0) + thor (>= 1.1.0) + benchmark (0.4.1) + bigdecimal (3.3.1) + bip-schnorr (0.7.0) + ecdsa_ext (~> 0.5.0) + bitcoinrb (1.7.0) + base32 (>= 0.3.4) + bech32 (>= 1.3.0) + bip-schnorr (>= 0.7.0) + daemon-spawn + ecdsa_ext (~> 0.5.1) + eventmachine + eventmachine_httpserver + ffi + iniparse + json_pure (>= 2.3.1) + leb128 (~> 1.0.0) + murmurhash3 (~> 0.1.7) + siphash + thor + block_io (3.1.0) + bitcoinrb (>= 1.2.1, < 2) + oj (~> 3.0, < 4) + typhoeus (~> 1.0, < 2) + builder (3.3.0) coercible (1.0.0) descendants_tracker (~> 0.0.1) - concurrent-ruby (1.1.4) + colorize (0.8.1) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) + crass (1.0.6) + daemon-spawn (0.4.2) + database_cleaner-active_record (2.2.2) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0) + database_cleaner-core (2.0.1) + date (3.4.1) descendants_tracker (0.0.4) thread_safe (~> 0.3, >= 0.3.1) - diff-lcs (1.3) - equalizer (0.0.11) - i18n (1.2.0) + diff-lcs (1.6.2) + drb (2.2.3) + ecdsa (1.2.0) + ecdsa_ext (0.5.1) + ecdsa (~> 1.2.0) + erb (5.1.1) + erubi (1.13.1) + ethon (0.15.0) + ffi (>= 1.15.0) + eventmachine (1.2.7) + eventmachine_httpserver (0.2.1) + ffi (1.17.2) + ffi (1.17.2-aarch64-linux-gnu) + ffi (1.17.2-aarch64-linux-musl) + ffi (1.17.2-arm-linux-gnu) + ffi (1.17.2-arm-linux-musl) + ffi (1.17.2-arm64-darwin) + ffi (1.17.2-x86_64-darwin) + ffi (1.17.2-x86_64-linux-gnu) + ffi (1.17.2-x86_64-linux-musl) + i18n (1.14.7) concurrent-ruby (~> 1.0) ice_nine (0.11.2) - jaro_winkler (1.5.1) - jwt (2.1.0) - minitest (5.11.3) - parallel (1.12.1) - parser (2.5.3.0) - ast (~> 2.4.0) - powerpack (0.1.2) - rainbow (3.0.0) - rake (10.5.0) - rspec (3.8.0) - rspec-core (~> 3.8.0) - rspec-expectations (~> 3.8.0) - rspec-mocks (~> 3.8.0) - rspec-core (3.8.0) - rspec-support (~> 3.8.0) - rspec-expectations (3.8.1) + iniparse (1.5.0) + io-console (0.8.1) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.15.1) + json_pure (2.8.1) + jwt (3.1.2) + base64 + language_server-protocol (3.17.0.5) + leb128 (1.0.0) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + minitest (5.26.0) + monetize (1.13.0) + money (~> 6.12) + money (6.19.0) + i18n (>= 0.6.4, <= 2) + money-rails (1.15.0) + activesupport (>= 3.0) + monetize (~> 1.9) + money (~> 6.13) + railties (>= 3.0) + murmurhash3 (0.1.7) + nokogiri (1.18.10-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-arm-linux-musl) + racc (~> 1.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-gnu) + racc (~> 1.4) + nokogiri (1.18.10-x86_64-linux-musl) + racc (~> 1.4) + oj (3.16.11) + bigdecimal (>= 3.0) + ostruct (>= 0.2) + ostruct (0.6.3) + parallel (1.27.0) + parser (3.3.9.0) + ast (~> 2.4.1) + racc + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.6.0) + psych (5.2.6) + date + stringio + racc (1.8.1) + rack (3.2.3) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (8.0.3) + actionpack (= 8.0.3) + activesupport (= 8.0.3) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.3.0) + rdoc (6.15.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.2) + io-console (~> 0.5) + rspec (3.13.1) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.5) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-mocks (3.8.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.6) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.8.0) - rspec-support (3.8.0) - rubocop (0.61.1) - jaro_winkler (~> 1.5.1) + rspec-support (~> 3.13.0) + rspec-support (3.13.6) + rubocop (1.81.1) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) - parser (>= 2.5, != 2.5.1.1) - powerpack (~> 0.1) + parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (~> 1.4.0) - ruby-progressbar (1.10.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.47.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + ruby-progressbar (1.13.0) + securerandom (0.4.1) + shoulda-matchers (6.5.0) + activesupport (>= 5.2.0) + siphash (0.0.1) + sqlite3 (2.7.4-aarch64-linux-gnu) + sqlite3 (2.7.4-aarch64-linux-musl) + sqlite3 (2.7.4-arm-linux-gnu) + sqlite3 (2.7.4-arm-linux-musl) + sqlite3 (2.7.4-arm64-darwin) + sqlite3 (2.7.4-x86_64-darwin) + sqlite3 (2.7.4-x86_64-linux-gnu) + sqlite3 (2.7.4-x86_64-linux-musl) + stringio (3.1.7) + thor (1.4.0) thread_safe (0.3.6) - tzinfo (1.2.5) - thread_safe (~> 0.1) - unicode-display_width (1.4.0) - virtus (1.0.5) + timeout (0.4.3) + tsort (0.2.0) + typhoeus (1.5.0) + ethon (>= 0.9.0, < 0.16.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + uri (1.0.4) + useragent (0.16.11) + virtus (2.0.0) axiom-types (~> 0.1) coercible (~> 1.0) descendants_tracker (~> 0.0, >= 0.0.3) - equalizer (~> 0.0, >= 0.0.9) - workflow (1.2.0) + workflow (3.1.1) + workflow-activerecord (6.0.1) + activerecord (>= 6.0) + workflow (~> 3.0) + zeitwerk (2.7.3) PLATFORMS - ruby + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + x86_64-darwin + x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES - bundler (~> 1.16) + bundler + database_cleaner-active_record payment_services! - rake (~> 10.0) - rspec (~> 3.0) - rubocop (~> 0.61) + rake + rspec + rubocop + shoulda-matchers + sqlite3 BUNDLED WITH - 1.17.1 + 2.7.2 diff --git a/README.md b/README.md index 0d2114e1..a0649f20 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Status](https://travis-ci.org/finfex/payment_services.svg?branch=master)](https: * Payeer * PerfectMoney * QIWI -* RBK +* Rbk * AliKassa * YandexMoney * AnyMoney diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 00000000..75b1f136 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +test: + adapter: sqlite3 + database: ':memory:' + pool: 5 + timeout: 5000 \ No newline at end of file diff --git a/lib/payment_services.rb b/lib/payment_services.rb index 8ec2b9c7..f5c010a9 100644 --- a/lib/payment_services.rb +++ b/lib/payment_services.rb @@ -4,24 +4,79 @@ require 'payment_services/version' require 'payment_services/configuration' +require 'payment_services/auto_logger' +require 'workflow-activerecord' +require 'money' module PaymentServices class << self attr_reader :configuration end + # Set explicit rounding mode to avoid deprecation warning + Money.rounding_mode = BigDecimal::ROUND_HALF_UP + require 'payment_services/base' + require 'payment_services/application_record' require 'payment_services/base/invoicer' require 'payment_services/base/payout_adapter' + require 'payment_services/base/client' + require 'payment_services/base/crypto_invoice' + require 'payment_services/base/crypto_payout' + require 'payment_services/base/fiat_invoice' + require 'payment_services/base/fiat_payout' + require 'payment_services/base/wallet' + require 'payment_services/base/p2p_bank_resolver' autoload :QIWI, 'payment_services/qiwi' autoload :AdvCash, 'payment_services/adv_cash' autoload :Payeer, 'payment_services/payeer' autoload :PerfectMoney, 'payment_services/perfect_money' - autoload :RBK, 'payment_services/rbk' + autoload :Rbk, 'payment_services/rbk' autoload :YandexMoney, 'payment_services/yandex_money' autoload :BlockIo, 'payment_services/block_io' autoload :CryptoApis, 'payment_services/crypto_apis' + autoload :AnyMoney, 'payment_services/any_money' + autoload :AppexMoney, 'payment_services/appex_money' + autoload :Kuna, 'payment_services/kuna' + autoload :Liquid, 'payment_services/liquid' + autoload :Obmenka, 'payment_services/obmenka' + autoload :Exmo, 'payment_services/exmo' + autoload :Binance, 'payment_services/binance' + autoload :MasterProcessing, 'payment_services/master_processing' + autoload :CryptoApisV2, 'payment_services/crypto_apis_v2' + autoload :Blockchair, 'payment_services/blockchair' + autoload :OkoOtc, 'payment_services/oko_otc' + autoload :Paylama, 'payment_services/paylama' + autoload :PaylamaCrypto, 'payment_services/paylama_crypto' + autoload :ExPay, 'payment_services/ex_pay' + autoload :OneCrypto, 'payment_services/one_crypto' + autoload :AnyPay, 'payment_services/any_pay' + autoload :MerchantAlikassa, 'payment_services/merchant_alikassa' + autoload :CoinPaymentsHub, 'payment_services/coin_payments_hub' + autoload :PayForU, 'payment_services/pay_for_u' + autoload :BestApi, 'payment_services/best_api' + autoload :PayForUH2h, 'payment_services/pay_for_u_h2h' + autoload :PaylamaSbp, 'payment_services/paylama_sbp' + autoload :PaylamaP2p, 'payment_services/paylama_p2p' + autoload :XPayPro, 'payment_services/x_pay_pro' + autoload :Wallex, 'payment_services/wallex' + autoload :Tronscan, 'payment_services/tronscan' + autoload :YourPayments, 'payment_services/your_payments' + autoload :Bridgex, 'payment_services/bridgex' + autoload :JustPays, 'payment_services/just_pays' + autoload :Transfera, 'payment_services/transfera' + autoload :Cryptomus, 'payment_services/cryptomus' + autoload :Paycraft, 'payment_services/paycraft' + autoload :Bovapay, 'payment_services/bovapay' + autoload :Erapay, 'payment_services/erapay' + autoload :MerchantAlikassaVirtual, 'payment_services/merchant_alikassa_virtual' + autoload :PaycraftVirtual, 'payment_services/paycraft_virtual' + autoload :XPayProVirtual, 'payment_services/x_pay_pro_virtual' + autoload :FireKassa, 'payment_services/fire_kassa' + autoload :Ff, 'payment_services/ff' + autoload :ManualByGroup, 'payment_services/manual_by_group' + autoload :Panda_Pay, 'payment_services/panda_pay' UnauthorizedPayout = Class.new StandardError diff --git a/lib/payment_services/adv_cash.rb b/lib/payment_services/adv_cash.rb index 999671f5..6c8862d4 100644 --- a/lib/payment_services/adv_cash.rb +++ b/lib/payment_services/adv_cash.rb @@ -5,7 +5,9 @@ module PaymentServices class AdvCash < Base autoload :Invoicer, 'payment_services/adv_cash/invoicer' + autoload :PayoutAdapter, 'payment_services/adv_cash/payout_adapter' register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter end end diff --git a/lib/payment_services/adv_cash/client.rb b/lib/payment_services/adv_cash/client.rb new file mode 100644 index 00000000..c5242b25 --- /dev/null +++ b/lib/payment_services/adv_cash/client.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'savon' + +class PaymentServices::AdvCash + class Client + include AutoLogger + TIMEOUT = 10 + SOAP_URL = 'https://account.volet.com/wsm/apiWebService?wsdl' + + def initialize(api_name:, authentication_token:, account_email:) + @api_name = api_name + @authentication_token = authentication_token + @account_email = account_email + end + + def create_invoice(params:) + safely_parse soap_request( + url: SOAP_URL, + operation: :create_p2p_order, + body: { + arg0: authentication_params, + arg1: params + } + ) + end + + def find_invoice(deposit_id:) + safely_parse soap_request( + url: SOAP_URL, + operation: :find_p2p_order_by_order_id, + body: { + arg0: authentication_params, + arg1: deposit_id + } + ) + end + + def create_payout(params:) + safely_parse soap_request( + url: SOAP_URL, + operation: :send_money, + body: { + arg0: authentication_params, + arg1: params + } + ) + end + + def find_transaction(id:) + safely_parse soap_request( + url: SOAP_URL, + operation: :find_transaction, + body: { + arg0: authentication_params, + arg1: id + } + ) + end + + private + + attr_reader :api_name, :authentication_token, :account_email + + def encrypted_token + sign_string = "#{authentication_token}:#{Time.now.utc.strftime('%Y%m%d:%H')}" + + Digest::SHA256.hexdigest(sign_string).upcase + end + + def soap_request(url:, operation:, body:) + logger.info "Request operation: #{operation} to #{url} with payload #{body}" + + Savon.client(wsdl: url, open_timeout: TIMEOUT, read_timeout: TIMEOUT).call(operation, message: body) + end + + def safely_parse(response) + res = response.body + logger.info "Response: #{res}" + res + rescue Savon::SOAPFault => err + logger.warn "Request failed #{response.class} #{response.body}" + Bugsnag.notify err do |report| + report.add_tab(:response, response_class: response.class, response_body: response.body) + end + response.body + end + + def authentication_params + { + apiName: api_name, + authenticationToken: encrypted_token, + accountEmail: account_email + } + end + end +end diff --git a/lib/payment_services/adv_cash/invoice.rb b/lib/payment_services/adv_cash/invoice.rb index 05c41488..0635b528 100644 --- a/lib/payment_services/adv_cash/invoice.rb +++ b/lib/payment_services/adv_cash/invoice.rb @@ -3,42 +3,26 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex class PaymentServices::AdvCash - class Invoice < ApplicationRecord - include Workflow + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'COMPLETED' + FAILED_PROVIDER_STATES = %w(EXPIRED CANCELED) self.table_name = 'adv_cash_invoices' - scope :ordered, -> { order(id: :desc) } - monetize :amount_cents, as: :amount - validates :amount_cents, :order_public_id, :state, presence: true - - workflow_column :state - workflow do - state :pending do - event :pay, transitions_to: :paid - event :cancel, transitions_to: :cancelled - end - - state :paid do - on_entry do - order.auto_confirm!(income_amount: amount) - end - end - state :cancelled + def formatted_amount + format('%.2f', amount.to_f) end - def pay(payload:) - update(payload: payload) - end + private - def order - Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE end - def formatted_amount - format('%.2f', amount.to_f) + def provider_failed? + provider_state.in? FAILED_PROVIDER_STATES end end end diff --git a/lib/payment_services/adv_cash/invoicer.rb b/lib/payment_services/adv_cash/invoicer.rb index fecfdffd..0470cdb3 100644 --- a/lib/payment_services/adv_cash/invoicer.rb +++ b/lib/payment_services/adv_cash/invoicer.rb @@ -3,57 +3,51 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex require_relative 'invoice' +require_relative 'client' class PaymentServices::AdvCash class Invoicer < ::PaymentServices::Base::Invoicer - ADV_CASH_URL = 'https://wallet.advcash.com/sci/' - def create_invoice(money) Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_invoice(params: invoice_params).dig(:create_p2p_order_response, :return) + + invoice.update!( + deposit_id: response[:order_id], + pay_url: response[:payment_url] + ) end - def invoice_form_data # rubocop:disable Metrics/MethodLength, Metrics/AbcSize - invoice = Invoice.find_by!(order_public_id: order.public_id) + def pay_invoice_url + invoice.present? ? URI.parse(invoice.reload.pay_url) : '' + end - form_data = { - email: order.income_wallet.adv_cash_merchant_email.presence || - raise("Не установлено поле adv_cash_merchant_email у кошелька #{order.income_wallet.id}"), - shop_name: order.income_wallet.merchant_id.presence || - raise("Не установлено поле merchant_id у кошелька #{order.income_wallet.id}"), - amount: invoice.formatted_amount, - currency: invoice.amount.currency.to_s, - order_id: invoice.order_public_id - } + def async_invoice_state_updater? + true + end - sign_array = [ - form_data[:email], - form_data[:shop_name], - form_data[:amount], - form_data[:currency], - order.income_wallet.api_key, - form_data[:order_id] - ] - signature = Digest::SHA256.hexdigest(sign_array.join(':')) + def update_invoice_state! + transaction = client.find_invoice(deposit_id: invoice.deposit_id).dig(:find_p2p_order_by_order_id_response, :return) + invoice.update_state_by_provider(transaction[:status]) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + def invoice_params + currency = invoice.amount_currency.to_s + currency = 'RUR' if currency == 'RUB' { - url: ADV_CASH_URL, - method: 'post', - inputs: { - ac_account_email: form_data[:email], - ac_sci_name: form_data[:shop_name], - ac_order_id: form_data[:order_id], - ac_sign: signature, - ac_status_url: "#{routes_helper.public_public_callbacks_api_root_url}/v1/adv_cash/receive_payment", - ac_success_url: routes_helper.public_payment_status_success_url(order_id: order.public_id), - ac_success_method: 'get', - ac_fail_url: routes_helper.public_payment_status_fail_url(order_id: order.public_id), - ac_fail_method: 'get', - ac_status_url_method: 'post', - ac_amount: form_data[:amount], - ac_currency: form_data[:currency], - ac_comments: I18n.t('payment_systems.default_product', order_id: order.public_id) - } + amount: invoice.formatted_amount, + currency: currency, + receiver: order.income_wallet.adv_cash_merchant_email, + orderId: order.public_id.to_s, + redirectUrl: order.success_redirect } end + + def client + @client ||= Client.new(api_name: order.income_wallet.merchant_id, authentication_token: api_key, account_email: order.income_wallet.adv_cash_merchant_email) + end end end diff --git a/lib/payment_services/adv_cash/payout.rb b/lib/payment_services/adv_cash/payout.rb new file mode 100644 index 00000000..6617c9aa --- /dev/null +++ b/lib/payment_services/adv_cash/payout.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class PaymentServices::AdvCash + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + self.table_name = 'advcash_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(withdrawal_id:) + update(withdrawal_id: withdrawal_id) + end + + def update_state_by_provider(state) + update!(provider_state: state) + + confirm! if success? + fail! if failed? + end + + def build_note + "#{order_payout.order.public_id}-#{order_payout.id}" + end + + private + + def order_payout + @order_payout ||= OrderPayout.find(order_payout_id) + end + + def success? + provider_state == 'COMPLETED' + end + + def failed? + provider_state == 'CANCELED' + end + end +end diff --git a/lib/payment_services/adv_cash/payout_adapter.rb b/lib/payment_services/adv_cash/payout_adapter.rb new file mode 100644 index 00000000..d1d60d02 --- /dev/null +++ b/lib/payment_services/adv_cash/payout_adapter.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::AdvCash + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + response = client.find_transaction(id: payout.withdrawal_id) + + raise "Can't get withdrawal details: #{response[:exception]}" if response[:exception] + + provider_state = response.dig(:find_transaction_response, :return, :status) + payout.update_state_by_provider(provider_state) if provider_state + + response + end + + private + + # NOTE: AdvCash использует коды 90х годов + def iso_code(currency) + actual_iso_code = currency.iso_code + + actual_iso_code == 'RUB' ? 'RUR' : actual_iso_code + end + + def make_payout(amount:, destination_account:, order_payout_id:) + payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + + params = { + amount: amount.to_d.round(2), + currency: iso_code(wallet.currency), + walletId: destination_account, + savePaymentTemplate: false, + note: payout.build_note + } + response = client.create_payout(params: params) + + raise "Can't process payout: #{response[:exception]}" if response[:exception] + + withdrawal_id = response.dig(:send_money_response, :return) + payout.pay!(withdrawal_id: withdrawal_id) if withdrawal_id + end + + def client + @client ||= Client.new(api_name: wallet.merchant_id, authentication_token: api_key, account_email: wallet.adv_cash_merchant_email) + end + end +end diff --git a/lib/payment_services/ali_kassa/invoice.rb b/lib/payment_services/ali_kassa/invoice.rb index 471e5e3f..2db3d16f 100644 --- a/lib/payment_services/ali_kassa/invoice.rb +++ b/lib/payment_services/ali_kassa/invoice.rb @@ -3,8 +3,8 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex class PaymentServices::AliKassa - class Invoice < ApplicationRecord - include Workflow + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord self.table_name = 'ali_kassa_invoices' scope :ordered, -> { order(id: :desc) } diff --git a/lib/payment_services/ali_kassa/invoicer.rb b/lib/payment_services/ali_kassa/invoicer.rb index 1ea5e010..880835e4 100644 --- a/lib/payment_services/ali_kassa/invoicer.rb +++ b/lib/payment_services/ali_kassa/invoicer.rb @@ -32,6 +32,7 @@ def create_invoice(money) def invoice_form_data pay_way = order.income_payment_system.payway + invoice_params = { merchantUuid: order.income_wallet.merchant_id, orderId: order.public_id, @@ -40,7 +41,9 @@ def invoice_form_data desc: I18n.t('payment_systems.default_product', order_id: order.public_id), lifetime: ALIKASSA_TIME_LIMIT, payWayVia: pay_way&.upcase_first, - customerEmail: order.user.try(:email) + customerEmail: order.user.try(:email), + urlSuccess: order.success_redirect, + urlFail: order.failed_redirect } invoice_params = assign_additional_params(invoice_params: invoice_params, pay_way: pay_way) invoice_params[:sign] = calculate_signature(invoice_params) @@ -73,7 +76,7 @@ def assign_additional_params(invoice_params:, pay_way:) def calculate_signature(params) sign_string = params.sort_by { |k, _v| k }.map(&:last).join(':') - sign_string += ":#{order.income_wallet.api_key}" + sign_string += ":#{api_key}" Digest::MD5.base64digest(sign_string) end diff --git a/lib/payment_services/ali_kassa_peer_to_peer/invoice.rb b/lib/payment_services/ali_kassa_peer_to_peer/invoice.rb index 83b5cf5b..d9c85cd7 100644 --- a/lib/payment_services/ali_kassa_peer_to_peer/invoice.rb +++ b/lib/payment_services/ali_kassa_peer_to_peer/invoice.rb @@ -3,8 +3,8 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex class PaymentServices::AliKassaPeerToPeer - class Invoice < ApplicationRecord - include Workflow + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord self.table_name = 'ali_kassa_p2p_invoices' scope :ordered, -> { order(id: :desc) } diff --git a/lib/payment_services/ali_kassa_peer_to_peer/invoicer.rb b/lib/payment_services/ali_kassa_peer_to_peer/invoicer.rb index 704c8cb4..916ddee2 100644 --- a/lib/payment_services/ali_kassa_peer_to_peer/invoicer.rb +++ b/lib/payment_services/ali_kassa_peer_to_peer/invoicer.rb @@ -16,6 +16,7 @@ def create_invoice(money) def invoice_form_data pay_way = order.income_payment_system.payway&.capitalize + invoice_params = { merchantUuid: order.income_wallet.merchant_id, orderId: order.public_id, @@ -23,7 +24,9 @@ def invoice_form_data currency: ALIKASSA_RUB_CURRENCY, payWayVia: pay_way, desc: I18n.t('payment_systems.default_product', order_id: order.public_id), - customerEmail: order.user.try(:email) + customerEmail: order.user.try(:email), + urlSuccess: order.success_redirect, + urlFail: order.failed_redirect } invoice_params[:payWayOn] = 'Qiwi' if pay_way == 'Qiwi' invoice_params[:number] = order.income_account.gsub(/\D/, '') if order.income_payment_system.payway == ALIKASSA_CARD @@ -42,7 +45,7 @@ def invoice_form_data def calculate_signature(params) sign_string = params.sort_by { |k, _v| k }.map(&:last).join(':') - sign_string += ":#{order.income_wallet.api_key}" + sign_string += ":#{api_key}" Digest::MD5.base64digest(sign_string) end end diff --git a/lib/payment_services/all.rb b/lib/payment_services/all.rb index c2506361..225d03ce 100644 --- a/lib/payment_services/all.rb +++ b/lib/payment_services/all.rb @@ -21,3 +21,43 @@ require 'payment_services/any_money' require 'payment_services/block_io' require 'payment_services/crypto_apis' +require 'payment_services/appex_money' +require 'payment_services/kuna' +require 'payment_services/liquid' +require 'payment_services/obmenka' +require 'payment_services/exmo' +require 'payment_services/binance' +require 'payment_services/master_processing' +require 'payment_services/crypto_apis_v2' +require 'payment_services/blockchair' +require 'payment_services/oko_otc' +require 'payment_services/paylama' +require 'payment_services/paylama_crypto' +require 'payment_services/ex_pay' +require 'payment_services/one_crypto' +require 'payment_services/any_pay' +require 'payment_services/merchant_alikassa' +require 'payment_services/coin_payments_hub' +require 'payment_services/pay_for_u' +require 'payment_services/best_api' +require 'payment_services/pay_for_u_h2h' +require 'payment_services/paylama_sbp' +require 'payment_services/paylama_p2p' +require 'payment_services/x_pay_pro' +require 'payment_services/wallex' +require 'payment_services/tronscan' +require 'payment_services/your_payments' +require 'payment_services/bridgex' +require 'payment_services/just_pays' +require 'payment_services/transfera' +require 'payment_services/cryptomus' +require 'payment_services/paycraft' +require 'payment_services/bovapay' +require 'payment_services/erapay' +require 'payment_services/merchant_alikassa_virtual' +require 'payment_services/paycraft_virtual' +require 'payment_services/x_pay_pro_virtual' +require 'payment_services/fire_kassa' +require 'payment_services/ff' +require 'payment_services/manual_by_group' +require 'payment_services/panda_pay' diff --git a/lib/payment_services/any_money.rb b/lib/payment_services/any_money.rb index 35c81c54..77609f80 100644 --- a/lib/payment_services/any_money.rb +++ b/lib/payment_services/any_money.rb @@ -5,6 +5,8 @@ module PaymentServices class AnyMoney < Base autoload :Invoicer, 'payment_services/any_money/invoicer' + autoload :PayoutAdapter, 'payment_services/any_money/payout_adapter' register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter end end diff --git a/lib/payment_services/any_money/client.rb b/lib/payment_services/any_money/client.rb new file mode 100644 index 00000000..ecd73beb --- /dev/null +++ b/lib/payment_services/any_money/client.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +class PaymentServices::AnyMoney + class Client + include AutoLogger + TIMEOUT = 10 + API_URL = 'https://api.any.money/' + API_VERSION = '2.0' + + def initialize(merchant_id:, api_key:) + @merchant_id = merchant_id + @api_key = api_key + end + + def create(params:) + request_for('payout.create', params) + end + + def get(params:) + request_for('payout.get', params) + end + + private + + attr_reader :merchant_id, :api_key + + def request_for(method, params) + safely_parse http_request( + url: API_URL, + method: :POST, + body: { + 'method': method, + 'params': params, + 'jsonrpc': API_VERSION, + 'id': '1' + } + ) + end + + def http_request(url:, method:, body: nil) + uri = URI.parse(url) + https = http(uri) + request = build_request(uri: uri, method: method, body: body) + logger.info "Request type: #{method} to #{uri} with payload #{request.body}" + https.request(request) + end + + def build_request(uri:, method:, body: nil) + request = if method == :POST + Net::HTTP::Post.new(uri.request_uri, headers(body[:params])) + elsif method == :GET + Net::HTTP::Get.new(uri.request_uri, headers(body[:params])) + else + raise "Запрос #{method} не поддерживается!" + end + request.body = (body.present? ? body : {}).to_json + request + end + + def http(uri) + Net::HTTP.start(uri.host, uri.port, + use_ssl: true, + verify_mode: OpenSSL::SSL::VERIFY_NONE, + open_timeout: TIMEOUT, + read_timeout: TIMEOUT) + end + + def headers(params) + utc_now = Time.now.to_i.to_s + + { + 'Content-Type': 'application/json', + 'x-merchant': merchant_id.to_s, + 'x-signature': build_signature(params, utc_now), + 'x-utc-now-ms': utc_now + } + end + + def build_signature(params, utc_now) + sign_string = params.sort_by { |k, _v| k }.map(&:last).join.downcase + utc_now + + OpenSSL::HMAC.hexdigest('SHA512', api_key, sign_string) + end + + def safely_parse(response) + res = JSON.parse(response.body).with_indifferent_access + logger.info "Response: #{res}" + res + rescue JSON::ParserError => err + logger.warn "Request failed #{response.class} #{response.body}" + Bugsnag.notify err do |report| + report.add_tab(:response, response_class: response.class, response_body: response.body) + end + response.body + end + end +end diff --git a/lib/payment_services/any_money/invoice.rb b/lib/payment_services/any_money/invoice.rb index 4b574993..6eb682c6 100644 --- a/lib/payment_services/any_money/invoice.rb +++ b/lib/payment_services/any_money/invoice.rb @@ -3,8 +3,8 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex class PaymentServices::AnyMoney - class Invoice < ApplicationRecord - include Workflow + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord self.table_name = 'any_money_invoices' scope :ordered, -> { order(id: :desc) } diff --git a/lib/payment_services/any_money/invoicer.rb b/lib/payment_services/any_money/invoicer.rb index 273a7878..fc67e53f 100644 --- a/lib/payment_services/any_money/invoicer.rb +++ b/lib/payment_services/any_money/invoicer.rb @@ -24,7 +24,10 @@ def invoice_form_data expiry: ANYMONEY_TIME_LIMIT, payway: payway, callback_url: order.income_payment_system.callback_url, - client_email: order.user&.email + client_email: order.user&.email, + merchant_payfee: order.income_payment_system.transfer_comission_payer_shop? ? "1" : "0", + return_url: order.success_redirect, + return_url_fail: order.failed_redirect } { url: ANYMONEY_PAYMENT_FORM_URL, @@ -37,7 +40,7 @@ def invoice_form_data def build_signature(params) sign_string = params.sort_by { |k, _v| k }.map(&:last).join.downcase - OpenSSL::HMAC.hexdigest('SHA512', order.income_wallet.api_key, sign_string) + OpenSSL::HMAC.hexdigest('SHA512', api_key, sign_string) end private diff --git a/lib/payment_services/any_money/payout.rb b/lib/payment_services/any_money/payout.rb new file mode 100644 index 00000000..ed3ec2fb --- /dev/null +++ b/lib/payment_services/any_money/payout.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class PaymentServices::AnyMoney + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + self.table_name = 'any_money_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(externalid:) + update(externalid: externalid) + end + + def success? + status == 'done' + end + + def status_failed? + status == 'fail' || status == 'reject' + end + end +end diff --git a/lib/payment_services/any_money/payout_adapter.rb b/lib/payment_services/any_money/payout_adapter.rb new file mode 100644 index 00000000..ab2a1dcd --- /dev/null +++ b/lib/payment_services/any_money/payout_adapter.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::AnyMoney + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + @payout_id = payout_id + return if payout.pending? + + params = { + externalid: payout.externalid.to_s + } + + response = client.get(params: params) + raise "Can't get order details: #{response[:error][:message]}" if response.dig(:error) + + result = response[:result] + payout.update!(status: result[:status]) if result[:status] + payout.confirm! if payout.success? + payout.fail! if payout.status_failed? + + result + end + + def payout + @payout ||= Payout.find_by(id: payout_id) + end + + private + + attr_accessor :payout_id + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout_id = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id).id + + params = { + amount: amount.to_s, + externalid: @payout_id.to_s, + out_curr: wallet.currency.to_s.upcase, + payway: wallet.payment_system.payway, + payee: destination_account + } + response = client.create(params: params) + raise "Can't process payout: #{response[:error][:message]}" if response.dig(:error) + + result = response[:result] + payout.pay!(externalid: result[:externalid]) if result[:externalid] + end + + def client + @client ||= Client.new(merchant_id: wallet.merchant_id, api_key: api_key) + end + end +end diff --git a/lib/payment_services/any_pay.rb b/lib/payment_services/any_pay.rb new file mode 100644 index 00000000..01f92cd5 --- /dev/null +++ b/lib/payment_services/any_pay.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class AnyPay < Base + autoload :Invoicer, 'payment_services/any_pay/invoicer' + autoload :PayoutAdapter, 'payment_services/any_pay/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/any_pay/client.rb b/lib/payment_services/any_pay/client.rb new file mode 100644 index 00000000..c30fcac4 --- /dev/null +++ b/lib/payment_services/any_pay/client.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class PaymentServices::AnyPay + class Client < ::PaymentServices::Base::Client + PROJECT_ID = 11555 + API_URL = "https://anypay.io/api" + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + params = { project_id: PROJECT_ID }.merge(params) + request_body = params.merge(sign: build_signature(method_name: 'create-payment', params: params)) + safely_parse(http_request( + url: "#{API_URL}/create-payment/#{secret_key}", + method: :POST, + body: request_body, + headers: build_headers + )).dig('result') + end + + def transaction(deposit_id:) + params = { project_id: PROJECT_ID, trans_id: deposit_id } + request_body = params.merge(sign: build_signature(method_name: 'payments', params: params)) + safely_parse(http_request( + url: "#{API_URL}/payments/#{secret_key}", + method: :POST, + body: request_body, + headers: build_headers + )).dig('result', 'payments', deposit_id) + end + + def create_payout(params:) + request_body = params.merge(sign: build_payout_signature(method_name: 'create-payout', params: params)) + safely_parse(http_request( + url: "#{API_URL}/create-payout/#{secret_key}", + method: :POST, + body: request_body, + headers: build_headers + )).dig('result') + end + + def payout(withdrawal_id:) + params = { trans_id: withdrawal_id } + request_body = params.merge(sign: build_payout_signature(method_name: 'payouts', params: params)) + safely_parse(http_request( + url: "#{API_URL}/payouts/#{secret_key}", + method: :POST, + body: request_body, + headers: build_headers + )).dig('result', 'payouts', withdrawal_id) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers + { + 'Accept' => 'application/json', + 'Content-Type' => 'multipart/form-data' + } + end + + def build_signature(method_name:, params:) + sign_string = [ + method_name, secret_key, params[:project_id], params[:pay_id], + params[:amount], params[:currency], params[:desc], params[:method], api_key + ].join + sha256_hex(sign_string) + end + + def build_payout_signature(method_name:, params:) + sign_string = [ + method_name, secret_key, params[:payout_id], params[:payout_type], + params[:amount], params[:wallet], api_key + ].join + sha256_hex(sign_string) + end + + def sha256_hex(sign_string) + Digest::SHA256.hexdigest(sign_string) + end + + def build_request(uri:, method:, body: nil, headers: nil) + request = if method == :POST + Net::HTTP::Post.new(uri.request_uri, headers) + elsif method == :GET + Net::HTTP::Get.new(uri.request_uri, headers) + else + raise "Запрос #{method} не поддерживается!" + end + request.set_form_data(body) + request + end + end +end diff --git a/lib/payment_services/any_pay/invoice.rb b/lib/payment_services/any_pay/invoice.rb new file mode 100644 index 00000000..6d203272 --- /dev/null +++ b/lib/payment_services/any_pay/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::AnyPay + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'paid' + FAILED_PROVIDER_STATES = %w(canceled expired error) + + self.table_name = 'any_pay_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state.in? FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/any_pay/invoicer.rb b/lib/payment_services/any_pay/invoicer.rb new file mode 100644 index 00000000..96643915 --- /dev/null +++ b/lib/payment_services/any_pay/invoicer.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::AnyPay + class Invoicer < ::PaymentServices::Base::Invoicer + QIWI_PAYMENT_METHOD = 'qiwi' + CARD_PAYMENT_METHOD = 'card' + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_invoice(params: invoice_params) + + invoice.update!( + deposit_id: response['transaction_id'], + pay_url: response['payment_url'] + ) + end + + def pay_invoice_url + invoice.present? ? URI.parse(invoice.reload.pay_url) : '' + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.transaction(deposit_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction['status']) if transaction + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :income_payment_system, to: :order + delegate :currency, to: :income_payment_system + + def invoice_params + { + pay_id: order.public_id.to_s, + amount: invoice.amount.to_f, + currency: currency.to_s, + desc: order.public_id.to_s, + method: payment_method, + email: order.user_email, + success_url: order.success_redirect, + fail_url: order.failed_redirect + } + end + + def payway + @payway ||= order.income_payment_system.payway.inquiry + end + + def payment_method + payway.qiwi? ? QIWI_PAYMENT_METHOD : CARD_PAYMENT_METHOD + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/any_pay/payout.rb b/lib/payment_services/any_pay/payout.rb new file mode 100644 index 00000000..4faa0b0c --- /dev/null +++ b/lib/payment_services/any_pay/payout.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::AnyPay + class Payout < ::PaymentServices::Base::FiatPayout + SUCCESS_PROVIDER_STATE = 'paid' + FAILED_PROVIDER_STATES = %w(canceled blocked) + + self.table_name = 'any_pay_payouts' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state.in? FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/any_pay/payout_adapter.rb b/lib/payment_services/any_pay/payout_adapter.rb new file mode 100644 index 00000000..a9c0657c --- /dev/null +++ b/lib/payment_services/any_pay/payout_adapter.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::AnyPay + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + PAYOUT_TYPE = 'qiwi' + COMMISSION_PAYEER = 'balance' + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + provider_payout = client.payout(withdrawal_id: payout.withdrawal_id) + payout.update_state_by_provider(provider_payout['status']) if provider_payout + provider_payout + end + + private + + attr_reader :payout + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + response = client.create_payout(params: payout_params) + + payout.pay!(withdrawal_id: response['transaction_id']) + end + + def payout_params + { + payout_id: payout.order_payout_id, + payout_type: PAYOUT_TYPE, + amount: payout.amount.to_f, + wallet: payout.destination_account[1..-1], + commission_type: COMMISSION_PAYEER + } + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/appex_money.rb b/lib/payment_services/appex_money.rb new file mode 100644 index 00000000..04e166e2 --- /dev/null +++ b/lib/payment_services/appex_money.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class AppexMoney < Base + autoload :PayoutAdapter, 'payment_services/appex_money/payout_adapter' + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/appex_money/client.rb b/lib/payment_services/appex_money/client.rb new file mode 100644 index 00000000..0c51106c --- /dev/null +++ b/lib/payment_services/appex_money/client.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require 'digest' +require 'securerandom' + +class PaymentServices::AppexMoney + class Client + include AutoLogger + TIMEOUT = 60 + API_URL = 'https://ecommerce.appexmoney.com/api/' + + def initialize(num_ps:, first_secret_key:, second_secret_key:) + @num_ps = num_ps + @first_secret_key = first_secret_key + @second_secret_key = second_secret_key + end + + def create(params:) + params = params.merge( + account: num_ps, + nonce: SecureRandom.hex(10) + ) + params[:signature] = create_signature(params) + + safely_parse http_request( + url: API_URL + 'payout/execute', + method: :POST, + body: params + ) + end + + def get(params:) + params = params.merge( + account: num_ps, + nonce: SecureRandom.hex(10) + ) + params[:signature] = refresh_signature(params) + + safely_parse http_request( + url: API_URL + 'payout/status', + method: :POST, + body: params + ) + end + + private + + attr_reader :num_ps, :first_secret_key, :second_secret_key + + def http_request(url:, method:, body: nil) + uri = URI.parse(url) + https = http(uri) + request = build_request(uri: uri, method: method, body: body) + logger.info "Request type: #{method} to #{uri} with payload #{request.body}" + https.request(request) + end + + def build_request(uri:, method:, body: nil) + request = if method == :POST + Net::HTTP::Post.new(uri.request_uri, headers) + elsif method == :GET + Net::HTTP::Get.new(uri.request_uri, headers) + else + raise "Запрос #{method} не поддерживается!" + end + request.body = (body.present? ? body : {}).to_json + request + end + + def headers + { + 'Content-Type': 'application/json' + } + end + + def http(uri) + Net::HTTP.start(uri.host, uri.port, + use_ssl: true, + verify_mode: OpenSSL::SSL::VERIFY_NONE, + open_timeout: TIMEOUT, + read_timeout: TIMEOUT) + end + + def create_signature(params) + card_number = params[:params] + masked_params = card_number[0..5] + '*' * 6 + card_number[-4..card_number.length] + sign_array = [ + params[:nonce], params[:account], params[:operator], masked_params, params[:amount], + params[:amountcurr], params[:number], first_secret_key, second_secret_key + ] + + Digest::MD5.hexdigest(sign_array.join(':')).upcase + end + + def refresh_signature(params) + sign_array = [ + params[:nonce], params[:account], params[:number], + '', first_secret_key, second_secret_key + ] + Digest::MD5.hexdigest(sign_array.join(':')).upcase + end + + def safely_parse(response) + res = JSON.parse(response.body).with_indifferent_access + logger.info "Response: #{res}" + res + rescue JSON::ParserError => err + logger.warn "Request failed #{response.class} #{response.body}" + Bugsnag.notify err do |report| + report.add_tab(:response, response_class: response.class, response_body: response.body) + end + response.body + end + end +end diff --git a/lib/payment_services/appex_money/payout.rb b/lib/payment_services/appex_money/payout.rb new file mode 100644 index 00000000..474b238a --- /dev/null +++ b/lib/payment_services/appex_money/payout.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class PaymentServices::AppexMoney + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + self.table_name = 'appex_money_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(number:) + update(number: number) + end + + def success? + status == 'OK' + end + + def status_failed? + status == 'error' + end + end +end diff --git a/lib/payment_services/appex_money/payout_adapter.rb b/lib/payment_services/appex_money/payout_adapter.rb new file mode 100644 index 00000000..776abe7d --- /dev/null +++ b/lib/payment_services/appex_money/payout_adapter.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::AppexMoney + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + @payout_id = payout_id + return if payout.pending? + + response = client.get(params: { number: number }) + raise "Can't get order details: #{response[:errortext]}" if response.dig(:errortext) + + payout.update!(status: response[:status]) if response[:status] + payout.confirm! if payout.success? + payout.fail! if payout.status_failed? + + response + end + + def payout + @payout ||= Payout.find_by(id: payout_id) + end + + def order_payout + @payout_number ||= OrderPayout.find(payout.order_payout_id) + end + + private + + attr_accessor :payout_id + + def number + "Kassa_#{order_payout.order.public_id}_payout_#{order_payout.id}" + end + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout_id = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id).id + routes_helper = Rails.application.routes.url_helpers + + params = { + amount: amount.to_d.round(2).to_s, + amountcurr: wallet.currency.to_s.upcase, + number: number, + operator: wallet.merchant_id, + params: destination_account, + callback_url: "#{routes_helper.public_public_callbacks_api_root_url}/v1/appex_money/confirm_payout" + } + response = client.create(params: params) + raise "Can't process payout: #{response[:errortext]}" if response.dig(:errortext) + + payout.pay!(number: response[:number]) if response[:number] + end + + def client + @client ||= begin + Client.new( + num_ps: wallet.num_ps, + first_secret_key: api_key, + second_secret_key: api_secret + ) + end + end + end +end diff --git a/lib/payment_services/application_record.rb b/lib/payment_services/application_record.rb new file mode 100644 index 00000000..98a83371 --- /dev/null +++ b/lib/payment_services/application_record.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'active_record' +require 'money-rails' +require 'money-rails/active_record/monetizable' +require 'money-rails/active_model/validator' + +module PaymentServices + class ApplicationRecord < ActiveRecord::Base + self.abstract_class = true + include MoneyRails::ActiveRecord::Monetizable + end +end diff --git a/lib/payment_services/auto_logger.rb b/lib/payment_services/auto_logger.rb new file mode 100644 index 00000000..2d805d47 --- /dev/null +++ b/lib/payment_services/auto_logger.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'logger' + +module AutoLogger + def logger + @logger ||= Logger.new(STDOUT) + end +end \ No newline at end of file diff --git a/lib/payment_services/base.rb b/lib/payment_services/base.rb index a86e77cd..7874afc3 100644 --- a/lib/payment_services/base.rb +++ b/lib/payment_services/base.rb @@ -6,7 +6,7 @@ module PaymentServices # Базовый класс для платежного сервиса. Описывает подсервисы и хранит конфигурацию # class Base - SUBSERVICES = %i[invoicer importer payout_adapter].freeze + SUBSERVICES = %i[invoicer importer payout_adapter client crypto_invoice crypto_payout fiat_invoice fiat_payout wallet p2p_bank_resolver].freeze # Реестр подсервисов class Registry @@ -26,6 +26,10 @@ def register(type, subservice_class) def registry @registry ||= Registry.new end + + def payout_contains_fee? + false + end end end end diff --git a/lib/payment_services/base/client.rb b/lib/payment_services/base/client.rb new file mode 100644 index 00000000..6fc86873 --- /dev/null +++ b/lib/payment_services/base/client.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class PaymentServices::Base + class Client + include AutoLogger + TIMEOUT = 30 + + def http_request(url:, method:, body: nil, headers: nil) + uri = URI.parse(url) + https = http(uri) + request = build_request(uri: uri, method: method, body: body, headers: headers) + logger.info "Request type: #{method} to #{uri} with payload #{request.body}" + https.request(request) + end + + def build_request(uri:, method:, body: nil, headers: nil) + request = if method == :POST + Net::HTTP::Post.new(uri.request_uri, headers) + elsif method == :GET + Net::HTTP::Get.new(uri.request_uri, headers) + elsif method == :PATCH + Net::HTTP::Patch.new(uri.request_uri, headers) + else + raise "Запрос #{method} не поддерживается!" + end + request.body = body + request + end + + def http(uri) + Net::HTTP.start(uri.host, uri.port, + use_ssl: true, + verify_mode: OpenSSL::SSL::VERIFY_NONE, + open_timeout: TIMEOUT, + read_timeout: TIMEOUT) + end + + def safely_parse(response) + res = JSON.parse(response.body) + logger.info "Response: #{res}" + res + rescue JSON::ParserError, TypeError => err + logger.warn "Request failed #{response.class} #{response.body}" + response.body + end + + private + + def build_headers(*) + raise 'not implemented' + end + end +end diff --git a/lib/payment_services/base/crypto_invoice.rb b/lib/payment_services/base/crypto_invoice.rb new file mode 100644 index 00000000..6377d13f --- /dev/null +++ b/lib/payment_services/base/crypto_invoice.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class PaymentServices::Base + class CryptoInvoice < PaymentServices::ApplicationRecord + self.abstract_class = true + + include WorkflowActiverecord + + scope :ordered, -> { order(id: :desc) } + + validates :amount_cents, :order_public_id, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :bind_transaction, transitions_to: :with_transaction + end + state :with_transaction do + on_entry do + order.make_reserve! + end + event :pay, transitions_to: :paid + end + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount, hash: transaction_id) + end + end + state :cancelled + end + + def order + Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + end + + private + + def pay(payload:) + update(payload: payload) + end + end +end diff --git a/lib/payment_services/base/crypto_payout.rb b/lib/payment_services/base/crypto_payout.rb new file mode 100644 index 00000000..45f951d6 --- /dev/null +++ b/lib/payment_services/base/crypto_payout.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class PaymentServices::Base + class CryptoPayout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + scope :ordered, -> { order(id: :desc) } + + validates :amount_cents, :destination_account, :state, :order_payout_id, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(withdrawal_id:) + update(withdrawal_id: withdrawal_id) + end + + def update_state_by_provider!(transaction) + update!( + provider_state: transaction.status, + fee: transaction.fee + ) + + confirm! if transaction.succeed? + fail! if transaction.failed? + end + end +end diff --git a/lib/payment_services/base/fiat_invoice.rb b/lib/payment_services/base/fiat_invoice.rb new file mode 100644 index 00000000..032c658d --- /dev/null +++ b/lib/payment_services/base/fiat_invoice.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class PaymentServices::Base + class FiatInvoice < PaymentServices::ApplicationRecord + self.abstract_class = true + + include WorkflowActiverecord + + scope :ordered, -> { order(id: :desc) } + + validates :amount_cents, :order_public_id, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + event :cancel, transitions_to: :cancelled + end + + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount) + end + end + state :cancelled + end + + def update_state_by_provider(state) + update!(provider_state: state) + + pay! if provider_succeed? + cancel! if provider_failed? + end + + def order + Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + end + + private + + def provider_succeed? + raise "Method `provider_succeed?` is not implemented for class #{self.class}" + end + + def provider_failed? + raise "Method `provider_failed?` is not implemented for class #{self.class}" + end + end +end diff --git a/lib/payment_services/base/fiat_payout.rb b/lib/payment_services/base/fiat_payout.rb new file mode 100644 index 00000000..d54a2d0a --- /dev/null +++ b/lib/payment_services/base/fiat_payout.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class PaymentServices::Base + class FiatPayout < PaymentServices::ApplicationRecord + self.abstract_class = true + + include WorkflowActiverecord + + scope :ordered, -> { order(id: :desc) } + + validates :amount_cents, :destination_account, :state, :order_payout_id, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(withdrawal_id:) + update(withdrawal_id: withdrawal_id) + end + + def update_state_by_provider(state) + update!(provider_state: state) + + confirm! if provider_succeed? + fail! if provider_failed? + end + + private + + def provider_succeed? + raise "Method `provider_succeed?` is not implemented for class #{self.class}" + end + + def provider_failed? + raise "Method `provider_failed?` is not implemented for class #{self.class}" + end + end +end diff --git a/lib/payment_services/base/invoicer.rb b/lib/payment_services/base/invoicer.rb index cb5a85da..3b42f848 100644 --- a/lib/payment_services/base/invoicer.rb +++ b/lib/payment_services/base/invoicer.rb @@ -41,6 +41,21 @@ def async_invoice_state_updater? private + def api_keys + @api_keys ||= begin + payment_service_name = self.class.name.delete_suffix('::Invoicer') + PaymentServiceApiKey.find_by(payment_service_name: payment_service_name) || raise("Ключи для #{payment_service_name} не заведены") + end + end + + def api_key + api_keys.income_api_key + end + + def api_secret + api_keys.income_api_secret + end + delegate :id, to: :order, prefix: true # AdvCash: diff --git a/lib/payment_services/base/p2p_bank_resolver.rb b/lib/payment_services/base/p2p_bank_resolver.rb new file mode 100644 index 00000000..9cbe6299 --- /dev/null +++ b/lib/payment_services/base/p2p_bank_resolver.rb @@ -0,0 +1,507 @@ +# frozen_string_literal: true + +class PaymentServices::Base::P2pBankResolver + include Virtus.model + + attribute :adapter + + PAYWAY_TO_CARD_BANK = { + 'PayForUH2h' => { + 'income' => { + 'uah' => { + '' => 'anyuabank' + }, + 'rub' => { + 'sberbank' => 'sberbank', + 'tinkoff' => 'tinkoff', + '' => 'sberbank' + }, + 'uzs' => { + 'humo' => 'humo', + '' => 'uzcard' + }, + 'azn' => { + 'leo' => 'leobank', + 'uni' => 'unibank', + '' => 'yapikredi' + } + }, + 'outcome' => { + 'uah' => { + '' => 'anyuabank' + }, + 'rub' => { + 'sberbank' => 'sberbank', + 'tinkoff' => 'tinkoff', + '' => 'sberbank' + }, + 'uzs' => { + 'humo' => 'humo', + '' => 'uzcard' + }, + 'azn' => { + 'leo' => 'leobank', + 'uni' => 'unibank', + '' => 'yapikredi' + } + } + }, + 'PaylamaP2p' => { + 'income' => { + 'rub' => { + 'sberbank' => 'sberbank', + 'tinkoff' => 'tinkoff', + '' => 'sberbank' + }, + 'uzs' => { + 'humo' => 'humo', + '' => 'visa/mc' + }, + 'azn' => { + 'leo' => 'leobank', + 'uni' => 'unibank', + '' => 'visa/mc' + } + }, + 'outcome' => { + 'rub' => { + 'sberbank' => 'sberbank', + 'tinkoff' => 'tinkoff', + '' => 'sberbank' + }, + 'uzs' => { + 'humo' => 'humo', + '' => 'visa/mc' + }, + 'azn' => { + 'leo' => 'leobank', + 'uni' => 'unibank', + '' => 'visa/mc' + } + } + }, + 'ExPay' => { + 'income' => { + 'rub' => { + 'sberbank' => 'SBERRUB', + 'tinkoff' => 'TCSBRUB', + '' => 'CARDRUB' + }, + 'uzs' => { + 'humo' => 'HUMOUZS', + '' => 'CARDUZS' + }, + 'azn' => { + '' => 'CARDAZN' + } + }, + 'outcome' => { + 'rub' => { + 'sberbank' => 'SBERRUB', + 'tinkoff' => 'TCSBRUB', + '' => 'CARDRUB' + }, + 'uzs' => { + 'humo' => 'HUMOUZS', + '' => 'CARDUZS' + }, + 'azn' => { + '' => 'CARDAZN' + } + } + }, + 'XPayPro' => { + 'income' => { + 'rub' => { + 'sberbank' => 'SBERBANK', + 'tinkoff' => 'TINKOFF', + 'raiffeisen' => 'RAIFFEISENBANK', + '' => 'BANK_ANY' + } + }, + 'outcome' => { + 'rub' => { + 'sberbank' => 'SBERBANK', + 'tinkoff' => 'TINKOFF', + 'raiffeisen' => 'RAIFFEISENBANK', + '' => 'BANK_ANY' + } + } + }, + 'AnyMoney' => { + 'income' => { + 'rub' => { + '' => 'qiwi' + }, + 'uah' => { + '' => 'visamc_p2p' + } + }, + 'outcome' => { + 'rub' => { + '' => 'qiwi' + }, + 'uah' => { + '' => 'visamc_p2p' + } + } + }, + 'OkoOtc' => { + 'income' => { + 'rub' => { + '' => 'Все банки РФ', + 'sberbank' => 'Сбербанк', + 'tinkoff' => 'Тинькофф', + 'qiwi' => 'Киви' + }, + 'eur' => { + '' => 'EUR' + }, + 'usd' => { + '' => 'USD' + }, + 'azn' => { + '' => 'AZN' + }, + 'kzt' => { + '' => 'KZT' + }, + 'uzs' => { + '' => 'UZS' + }, + 'usdt' => { + '' => 'USDT' + } + }, + 'outcome' => { + 'rub' => { + '' => 'Все банки РФ', + 'sberbank' => 'Сбербанк', + 'tinkoff' => 'Тинькофф', + 'qiwi' => 'Киви' + }, + 'eur' => { + '' => 'EUR' + }, + 'usd' => { + '' => 'USD' + }, + 'azn' => { + '' => 'AZN' + }, + 'kzt' => { + '' => 'KZT' + }, + 'uzs' => { + '' => 'UZS' + }, + 'usdt' => { + '' => 'USDT' + } + } + }, + 'Wallex' => { + 'income' => { + 'rub' => { + 'tinkoff' => 'tinkoff', + 'sberbank' => 'sber', + '' => 'sber' + } + }, + 'outcome' => { + 'rub' => { + 'tinkoff' => 'Тинькофф', + 'sberbank' => 'Сбер', + 'qiwi' => 'Киви', + '' => 'Все банки РФ' + } + } + }, + 'MerchantAlikassa' => { + 'income' => { + 'rub' => { + 'sberbank' => 'sberbank', + 'raiffeisen' => 'raiffeisen', + '' => 'sberbank' + } + }, + 'outcome' => { + 'sberbank' => 'sberbank', + 'raiffeisen' => 'raiffeisen', + '' => 'sberbank' + } + }, + 'YourPayments' => { + 'income' => { + 'rub' => { + 'tinkoff' => 'tinkoff', + 'sberbank' => 'sberbank', + 'raiffeisen' => 'raiffeisen', + '' => 'tinkoff' + } + }, + 'outcome' => { + 'rub' => { + 'tinkoff' => 'tinkoff', + 'sberbank' => 'sberbank', + 'raiffeisen' => 'raiffeisen', + '' => 'tinkoff' + } + } + }, + 'Bridgex' => { + 'income' => { + 'rub' => { + 'sberbank' => 'Sberbank', + 'tinkoff' => 'Tinkoff', + 'raiffeisen' => 'Raiffeisen', + '' => 'unused_param' + } + }, + 'outcome' => {} + }, + 'Transfera' => { + 'income' => { + 'rub' => { + 'sberbank' => 'Sber', + 'raiffeisen' => 'Raif', + '' => 'Mir' + } + }, + 'outcome' => {} + }, + 'Paycraft' => { + 'income' => { + 'rub' => { + 'sberbank' => 'Сбербанк', + 'tinkoff' => 'Тинькофф', + 'raiffeisen' => 'Райффайзенбанк', + '' => 'Межбанк' + } + }, + 'outcome' => {} + }, + 'Bovapay' => { + 'income' => { + 'rub' => { + 'sberbank' => 'sberbank', + 'raiffeisen' => 'raiffeisen', + '' => 'sberbank' + } + }, + 'outcome' => {} + }, + 'XPayProVirtual' => { + 'income' => { + 'rub' => { + 'sberbank' => 'SBERBANK', + 'tinkoff' => 'TINKOFF', + 'raiffeisen' => 'RAIFFEISENBANK', + '' => 'BANK_ANY' + } + }, + 'outcome' => {} + }, + 'FireKassa' => { + 'income' => { + 'rub' => { + 'sberbank' => 'sber', + 'tinkoff' => 'tinkoff', + '' => 'vmm' + } + }, + 'outcome' => {} + } + }.freeze + + PAYWAY_TO_SBP_BANK = { + 'OkoOtc' => { + 'income' => {}, + 'outcome' => {} + }, + 'Wallex' => { + 'income' => { + 'Тинькофф Банк' => 'tinkoff', + 'Сбер' => 'sber' + }, + 'outcome' => { + 'Тинькофф Банк' => '100000000004', + 'Сбер' => '100000000111', + 'Банк ВТБ' => '100000000005', + 'АЛЬФА-БАНК' => '100000000008', + 'Райффайзенбанк' => '100000000007', + 'Банк ОТКРЫТИЕ' => '100000000015', + 'Газпромбанк' => '100000000001', + 'Промсвязьбанк' => '100000000010', + 'Хоум кредит' => '100000000024', + 'Россельхозбанк' => '100000000020', + 'Совкомбанк' => '100000000013', + 'Точка ФК Открытие' => '100000000065' + } + }, + 'YourPayments' => { + 'income' => { + 'Тинькофф Банк' => 'tinkoff_sbp', + 'Сбер' => 'sberbank_sbp', + 'Банк ВТБ' => 'vtb_sbp', + 'АЛЬФА-БАНК' => 'alfabank_sbp', + 'Райффайзенбанк' => 'raiffeisen_sbp', + 'Банк ОТКРЫТИЕ' => 'tochka_sbp', + 'Газпромбанк' => 'gazprom_sbp', + 'Промсвязьбанк' => 'promsvyazbank_sbp', + 'Хоум кредит' => 'home_sbp', + 'Россельхозбанк' => 'rosselhozbank_sbp', + 'Совкомбанк' => 'sovcombank_sbp', + 'Точка ФК Открытие' => 'tochka_sbp' + }, + 'outcome' => { + 'Тинькофф Банк' => 'tinkoff_sbp', + 'Сбер' => 'sberbank_sbp', + 'Банк ВТБ' => 'vtb_sbp', + 'АЛЬФА-БАНК' => 'alfabank_sbp', + 'Райффайзенбанк' => 'raiffeisen_sbp', + 'Банк ОТКРЫТИЕ' => 'tochka_sbp', + 'Газпромбанк' => 'gazprom_sbp', + 'Промсвязьбанк' => 'promsvyazbank_sbp', + 'Хоум кредит' => 'home_sbp', + 'Россельхозбанк' => 'rosselhozbank_sbp', + 'Совкомбанк' => 'sovcombank_sbp', + 'Точка ФК Открытие' => 'tochka_sbp' + } + }, + 'Bridgex' => { + 'income' => { + 'Тинькофф Банк' => 'tinkoff', + 'Сбер' => 'sberbank', + 'Райффайзенбанк' => 'Raiffeisen' + }, + 'outcome' => {} + }, + 'MerchantAlikassa' => { + 'income' => { + 'Тинькофф Банк' => '100000000004', + 'Сбер' => '100000000111', + 'Банк ВТБ' => '100000000005', + 'АЛЬФА-БАНК' => '100000000008', + 'Райффайзенбанк' => '100000000007', + 'Банк ОТКРЫТИЕ' => '100000000015', + 'Газпромбанк' => '100000000001', + 'Промсвязьбанк' => '100000000010', + 'Хоум кредит' => '100000000024', + 'Россельхозбанк' => '100000000020', + 'Совкомбанк' => '100000000013', + 'Точка ФК Открытие' => '100000000065' + }, + 'outcome' => { + 'Тинькофф Банк' => '100000000004', + 'Сбер' => '100000000111', + 'Банк ВТБ' => '100000000005', + 'АЛЬФА-БАНК' => '100000000008', + 'Райффайзенбанк' => '100000000007', + 'Банк ОТКРЫТИЕ' => '100000000015', + 'Газпромбанк' => '100000000001', + 'Промсвязьбанк' => '100000000010', + 'Хоум кредит' => '100000000024', + 'Россельхозбанк' => '100000000020', + 'Совкомбанк' => '100000000013', + 'Точка ФК Открытие' => '100000000065' + } + }, + 'Bovapay' => { + 'income' => {}, + 'outcome' => { + 'Тинькофф Банк' => '100000000004', + 'Сбер' => '100000000111', + 'Банк ВТБ' => '100000000005', + 'Газпромбанк' => '100000000001', + 'АЛЬФА-БАНК' => '100000000008', + 'Совкомбанк' => '100000000013', + 'Банк ОТКРЫТИЕ' => '100000000015' + } + }, + 'Paycraft' => { + 'income' => {}, + 'outcome' => { + 'Тинькофф Банк' => 'Межбанк', + 'Сбер' => 'Сбер', + 'Банк ВТБ' => 'Межбанк', + 'АЛЬФА-БАНК' => 'Межбанк', + 'Райффайзенбанк' => 'Райффайзен', + 'Банк ОТКРЫТИЕ' => 'Межбанк', + 'Газпромбанк' => 'Межбанк', + 'Промсвязьбанк' => 'Межбанк', + 'Хоум кредит' => 'Межбанк', + 'Россельхозбанк' => 'Межбанк', + 'Совкомбанк' => 'Межбанк', + 'Точка ФК Открытие' => 'Межбанк' + } + }, + 'FireKassa' => { + 'income' => { + 'Тинькофф Банк' => '100000000004', + 'Сбер' => '100000000111', + 'Банк ВТБ' => '100000000005', + 'АЛЬФА-БАНК' => '100000000008', + 'Райффайзенбанк' => '100000000007', + 'Банк ОТКРЫТИЕ' => '100000000015', + 'Газпромбанк' => '100000000001', + 'Промсвязьбанк' => '100000000010', + 'Хоум кредит' => '100000000024', + 'Россельхозбанк' => '100000000020', + 'Совкомбанк' => '100000000013', + 'Точка ФК Открытие' => '100000000065' + }, + 'outcome' => {} + } + }.freeze + + def initialize(adapter:) + @adapter = adapter + @direction = adapter.class.name.demodulize == 'Invoicer' ? 'income' : 'outcome' + end + + def card_bank + PAYWAY_TO_CARD_BANK.dig(adapter_class_name, direction, currency, send("#{direction}_payment_system").bank_name.to_s) || raise("Нету доступного банка для шлюза #{adapter_class_name}") + end + + def sbp_bank + PAYWAY_TO_SBP_BANK.dig(adapter_class_name, direction, sbp_client_field).presence || + PAYWAY_TO_SBP_BANK.dig(adapter_class_name, direction, sbp_client_field_new) + end + + def sbp? + @sbp ||= currency.rub? && sbp_checkbox + end + + private + + attr_reader :direction + + delegate :income_sbp, to: :income_payment_system + delegate :outcome_sbp, to: :outcome_payment_system + delegate :income_currency, :income_payment_system, :outcome_currency, :outcome_payment_system, :income_unk, :outcome_unk, :income_bank_name, :outcome_bank_name, to: :order + + def order + @order ||= direction.inquiry.income? ? adapter.order : adapter.wallet_transfers.first.order_payout.order + end + + def adapter_class_name + @adapter_class_name ||= adapter.class.name.split('::')[1] + end + + def currency + @currency ||= send("#{direction}_currency").to_s.downcase.inquiry + end + + def sbp_client_field + @sbp_client_field ||= send("#{direction}_unk") + end + + def sbp_client_field_new + @sbp_client_field_new ||= send("#{direction}_bank_name") + end + + def sbp_checkbox + @sbp_checkbox ||= send("#{direction}_sbp") + end +end diff --git a/lib/payment_services/base/payout_adapter.rb b/lib/payment_services/base/payout_adapter.rb index 5df77743..78e0ba09 100644 --- a/lib/payment_services/base/payout_adapter.rb +++ b/lib/payment_services/base/payout_adapter.rb @@ -9,7 +9,9 @@ class PaymentServices::Base class PayoutAdapter include Virtus.model strict: true - attribute :wallet # , Wallet + attribute :wallet_transfers # , Array[WalletTransfer] + + delegate :payment_system, to: :wallet # amount - сумма выплаты (Money) # transaction_id - идентификатор транзакции (платежки) для записи в журнал на внешнем API @@ -26,8 +28,28 @@ def make_payout!(amount:, payment_card_details:, transaction_id:, destination_ac private + def api_keys + @api_keys ||= begin + payment_service_name = self.class.name.delete_suffix('::PayoutAdapter') + PaymentServiceApiKey.find_by(payment_service_name: payment_service_name) || raise("Ключи для #{payment_service_name} не заведены") + end + end + + def api_key + api_keys.outcome_api_key + end + + def api_secret + api_keys.outcome_api_secret + end + def make_payout(*) raise 'not implemented' end + + # NOTE: для адаптеров, которые использую один кошелек для выплат + def wallet + wallet_transfers.first.wallet + end end end diff --git a/lib/payment_services/base/wallet.rb b/lib/payment_services/base/wallet.rb new file mode 100644 index 00000000..0fedeeac --- /dev/null +++ b/lib/payment_services/base/wallet.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PaymentServices::Base + class Wallet + include Virtus.model + + attribute :address, String + attribute :name, String + attribute :memo, String + attribute :name_group, String + + def initialize(address:, name:, memo: nil, name_group: nil) + @address = address + @name = name + @memo = memo + @name_group = name_group + end + end +end diff --git a/lib/payment_services/best_api.rb b/lib/payment_services/best_api.rb new file mode 100644 index 00000000..0583327a --- /dev/null +++ b/lib/payment_services/best_api.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class BestApi < Base + autoload :Invoicer, 'payment_services/best_api/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/best_api/client.rb b/lib/payment_services/best_api/client.rb new file mode 100644 index 00000000..1776b1b3 --- /dev/null +++ b/lib/payment_services/best_api/client.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class PaymentServices::BestApi + class Client < ::PaymentServices::Base::Client + API_URL = 'https://nash-c6dd440834c0.herokuapp.com/api' + + def initialize(api_key:) + @api_key = api_key + end + + def income_wallet(amount:, currency:) + safely_parse(http_request( + url: "#{API_URL}/get_card/client/#{api_key}/amount/#{amount}/currency/#{currency}/niche/auto", + method: :GET, + headers: {} + )).first + end + + private + + attr_reader :api_key + end +end diff --git a/lib/payment_services/best_api/invoice.rb b/lib/payment_services/best_api/invoice.rb new file mode 100644 index 00000000..8d255b30 --- /dev/null +++ b/lib/payment_services/best_api/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::BestApi + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'fully paid' + FAILED_PROVIDER_STATE = 'trade archived' + + self.table_name = 'best_api_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/best_api/invoicer.rb b/lib/payment_services/best_api/invoicer.rb new file mode 100644 index 00000000..f96c9fd6 --- /dev/null +++ b/lib/payment_services/best_api/invoicer.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::BestApi + class Invoicer < ::PaymentServices::Base::Invoicer + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.income_wallet(amount: order.calculated_income_money.to_i, currency: currency.to_s) + + invoice.update!(deposit_id: response['trade']) + PaymentServices::Base::Wallet.new(address: prepare_card_number(response['card_number']), name: nil, name_group: response['trade']) + end + + def create_invoice(money) + invoice + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def client + @client ||= Client.new(api_key: api_key) + end + + def prepare_card_number(provider_card_number) + provider_card_number.split('|').first.split(' ').last + end + end +end diff --git a/lib/payment_services/binance.rb b/lib/payment_services/binance.rb new file mode 100644 index 00000000..f380be69 --- /dev/null +++ b/lib/payment_services/binance.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class Binance < Base + autoload :Invoicer, 'payment_services/binance/invoicer' + autoload :PayoutAdapter, 'payment_services/binance/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/binance/client.rb b/lib/payment_services/binance/client.rb new file mode 100644 index 00000000..0ad59fef --- /dev/null +++ b/lib/payment_services/binance/client.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class PaymentServices::Binance + class Client < ::PaymentServices::Base::Client + API_URL = 'https://api.binance.com' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def deposit_history(currency:) + query = build_query(params: { coin: currency }) + safely_parse http_request( + url: "#{API_URL}/sapi/v1/capital/deposit/hisrec?#{query}", + method: :GET, + headers: build_headers + ) + end + + def withdraw_history(currency:, network:) + query = build_query(params: { coin: currency, network: network }) + safely_parse http_request( + url: "#{API_URL}/sapi/v1/capital/withdraw/history?#{query}", + method: :GET, + headers: build_headers + ) + end + + def create_payout(params:) + query = build_query(params: params) + safely_parse http_request( + url: "#{API_URL}/sapi/v1/capital/withdraw/apply?#{query}", + method: :POST, + headers: build_headers + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_query(params:) + query = params.merge( + timestamp: time_now_milliseconds + ).compact.to_query + query += "&signature=#{build_signature(query)}" + query + end + + def time_now_milliseconds + Time.now.to_i * 1000 + end + + def build_headers + { + 'Content-Type' => 'application/x-www-form-urlencoded', + 'X-MBX-APIKEY' => api_key + } + end + + def build_signature(request_body) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret_key, request_body) + end + end +end diff --git a/lib/payment_services/binance/invoice.rb b/lib/payment_services/binance/invoice.rb new file mode 100644 index 00000000..e7d6ecf0 --- /dev/null +++ b/lib/payment_services/binance/invoice.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class PaymentServices::Binance + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + BINANCE_SUCCESS = [1, 6] + BINANCE_FAILED = 3 + + self.table_name = 'binance_invoices' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + + validates :amount_cents, :order_public_id, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + event :cancel, transitions_to: :cancelled + end + + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount, hash: transaction_id) + end + end + state :cancelled + end + + def update_state_by_provider(state) + update!(provider_state: state) + + pay! if success? + cancel! if failed? + end + + def order + @order ||= Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + end + + def token_address + order.income_payment_system.token_address.presence + end + + private + + def success? + BINANCE_SUCCESS.include? provider_state + end + + def failed? + provider_state == BINANCE_FAILED + end + end +end diff --git a/lib/payment_services/binance/invoicer.rb b/lib/payment_services/binance/invoicer.rb new file mode 100644 index 00000000..31e332f8 --- /dev/null +++ b/lib/payment_services/binance/invoicer.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Binance + class Invoicer < ::PaymentServices::Base::Invoicer + TRANSACTION_TIME_THRESHOLD = 30.minutes + DepositHistoryRequestFailed = Class.new StandardError + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + response = client.deposit_history(currency: invoice.amount_currency) + raise DepositHistoryRequestFailed, "Can't get deposit history: #{response['msg']}" if response.is_a? Hash + + transaction = find_transaction(transactions: response) + return if transaction.nil? + + update_invoice_details(transaction: transaction) + invoice.update_state_by_provider(transaction['status']) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def update_invoice_details(transaction:) + invoice.transaction_id ||= transaction['txId'] + invoice.transaction_created_at ||= parse_datetime_utc(transaction['insertTime']) + invoice.save! + end + + def parse_datetime_utc(timestamp_milliseconds) + DateTime.strptime((timestamp_milliseconds / 1000).to_i.to_s,'%s').utc + end + + def find_transaction(transactions:) + transactions.find { |transaction| matches_amount_network_and_timing?(transaction) } + end + + def matches_amount_network_and_timing?(transaction) + transaction['amount'].to_d == invoice.amount.to_d && match_network?(transaction) && match_time_interval?(transaction) + end + + def match_network?(transaction) + return true unless invoice.token_address + + transaction['network'] == invoice.token_address + end + + def match_time_interval?(transaction) + transaction_created_at_utc = parse_datetime_utc(transaction['insertTime']) + invoice_created_at_utc = invoice.created_at.utc + + invoice_created_at_utc < transaction_created_at_utc && created_in_valid_interval?(transaction_created_at_utc, invoice_created_at_utc) + end + + def created_in_valid_interval?(transaction_time, invoice_time) + interval = (transaction_time - invoice_time) + interval_in_minutes = (interval / 1.minute).round.minutes + interval_in_minutes < TRANSACTION_TIME_THRESHOLD + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/binance/payout.rb b/lib/payment_services/binance/payout.rb new file mode 100644 index 00000000..42c53e48 --- /dev/null +++ b/lib/payment_services/binance/payout.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class PaymentServices::Binance + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + BINANCE_SUCCESS = 6 + BINANCE_REJECTED = 3 + BINANCE_FAILURE = 5 + + self.table_name = 'binance_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(withdraw_id:) + update(withdraw_id: withdraw_id) + end + + def update_state_by_provider(state) + update!(provider_state: state) + + confirm! if success? + fail! if status_failed? + end + + def additional_info + order.outcome_fio.presence || order.outcome_unk.presence + end + + def has_additional_info? + !additional_info.nil? + end + + def token_address + order.outcome_payment_system.token_address.presence + end + + private + + def order + @order ||= order_payout.order + end + + def order_payout + @order_payout ||= OrderPayout.find(order_payout_id) + end + + def success? + provider_state == BINANCE_SUCCESS + end + + def status_failed? + provider_state == BINANCE_REJECTED || provider_state == BINANCE_FAILURE + end + end +end diff --git a/lib/payment_services/binance/payout_adapter.rb b/lib/payment_services/binance/payout_adapter.rb new file mode 100644 index 00000000..bcb7c499 --- /dev/null +++ b/lib/payment_services/binance/payout_adapter.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::Binance + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + Error = Class.new StandardError + PayoutCreateRequestFailed = Class.new Error + WithdrawHistoryRequestFailed = Class.new Error + + delegate :outcome_transaction_fee_amount, to: :payment_system + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + response = client.withdraw_history(currency: payout.amount_currency, network: payout.token_address) + raise WithdrawHistoryRequestFailed, "Can't get withdraw history: #{response['msg']}" if withdraw_history_response_failed?(response) + + transaction = response.find { |t| matches?(payout: payout, transaction: t) } + payout.update_state_by_provider(transaction['status']) if transaction.present? + transaction + end + + private + + def make_payout(amount:, destination_account:, order_payout_id:) + payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + payout_params = { + coin: payout.amount_currency, + amount: amount.to_d + (outcome_transaction_fee_amount || 0), + address: destination_account, + network: payout.token_address + } + payout_params[:addressTag] = payout.additional_info if payout.has_additional_info? + response = client.create_payout(params: payout_params) + raise PayoutCreateRequestFailed, "Can't create payout: #{response['msg']}" if create_payout_response_failed?(response) + + payout.pay!(withdraw_id: response['id']) + end + + def withdraw_history_response_failed?(response) + response.is_a? Hash + end + + def create_payout_response_failed?(response) + response['code'].present? + end + + def matches?(payout:, transaction:) + transaction['id'] == payout.withdraw_id && transaction['amount'].to_d == payout.amount.to_d + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/block_io.rb b/lib/payment_services/block_io.rb index 3638eeac..b379a8dd 100644 --- a/lib/payment_services/block_io.rb +++ b/lib/payment_services/block_io.rb @@ -5,7 +5,12 @@ module PaymentServices class BlockIo < Base autoload :PayoutAdapter, 'payment_services/block_io/payout_adapter' - + autoload :Invoicer, 'payment_services/block_io/invoicer' register :payout_adapter, PayoutAdapter + register :invoicer, Invoicer + + def self.payout_contains_fee? + true + end end end diff --git a/lib/payment_services/block_io/client.rb b/lib/payment_services/block_io/client.rb index 52585f07..e4583623 100644 --- a/lib/payment_services/block_io/client.rb +++ b/lib/payment_services/block_io/client.rb @@ -2,27 +2,75 @@ # Copyright (c) 2020 FINFEX https://github.com/finfex +require 'block_io' + class PaymentServices::BlockIo - class Client + class Client < ::PaymentServices::Base::Client include AutoLogger Error = Class.new StandardError + API_VERSION = 2 + API_URL = 'https://block.io/api/v2' - def initialize(api_key:, pin:) + def initialize(api_key:, pin: '') @api_key = api_key @pin = pin end - def make_payout(address:, amount:, nonce:) - BlockIo.set_options(api_key: api_key, pin: pin) - begin - BlockIo.withdraw(to_addresses: address, amounts: amount, nonce: nonce) - rescue Exception => error # BlockIo uses Exceptions instead StandardError - raise Error, error.to_s - end + def make_payout(address:, amount:, nonce:, fee_priority:) + logger.info "---- Request payout to: #{address}, on #{amount} ----" + transaction = prepare_transaction(amount: amount, address: address, fee_priority: fee_priority) + signed_transaction = block_io_client.create_and_sign_transaction(transaction) + submit_transaction_response = block_io_client.submit_transaction(transaction_data: signed_transaction) + logger.info "---- Response: #{submit_transaction_response.to_s} ----" + submit_transaction_response + rescue Exception, StandardError => error # BlockIo uses Exceptions instead StandardError + logger.error error.to_s + raise Error, error.to_s + end + + def prepare_transaction(amount:, address:, fee_priority:) + params = { api_key: api_key, amounts: amount, to_addresses: address, priority: fee_priority } + response = safely_parse(http_request( + url: "#{API_URL}/prepare_transaction?#{params.to_query}", + method: :GET, + headers: build_headers + )) + raise StandardError, response['data'] if response['status'] == 'fail' + response + end + + def income_transactions(address) + transactions(address: address, type: :received) + end + + def outcome_transactions(address) + transactions(address: address, type: :sent) + end + + def extract_transaction_id(response) + response.dig('data', 'txid') end private + def build_headers + {} + end + + def transactions(address:, type:) + logger.info "---- Request transactions info on #{address} ----" + transactions = block_io_client.get_transactions(type: type.to_s, addresses: address) + logger.info "---- Response: #{transactions} ----" + transactions + rescue Exception => error + logger.error error.to_s + raise Error, error.to_s + end + + def block_io_client + @block_io_client ||= BlockIo::Client.new(api_key: api_key, pin: pin, version: API_VERSION) + end + attr_reader :api_key, :pin end end diff --git a/lib/payment_services/block_io/invoice.rb b/lib/payment_services/block_io/invoice.rb new file mode 100644 index 00000000..0ce2991e --- /dev/null +++ b/lib/payment_services/block_io/invoice.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class PaymentServices::BlockIo + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord + self.table_name = 'block_io_invoices' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :address, :order_public_id, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :bind_transaction, transitions_to: :with_transaction + end + state :with_transaction do + on_entry do + order.make_reserve! + end + event :pay, transitions_to: :paid + event :cancel, transitions_to: :cancelled + end + + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount, hash: transaction_id) + end + end + state :cancelled + end + + def pay(payload:) + update(payload: payload) + end + + def order + Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + end + + def update_invoice_details(transaction:) + bind_transaction! if pending? + update!(transaction_created_at: transaction.created_at, transaction_id: transaction.id) + + pay!(payload: transaction) if transaction.successful? + end + end +end diff --git a/lib/payment_services/block_io/invoicer.rb b/lib/payment_services/block_io/invoicer.rb new file mode 100644 index 00000000..d6cc5939 --- /dev/null +++ b/lib/payment_services/block_io/invoicer.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' +require_relative 'transaction' + +class PaymentServices::BlockIo + class Invoicer < ::PaymentServices::Base::Invoicer + TransactionsHistoryRequestFailed = Class.new StandardError + RESPONSE_SUCCESS_STATUS = 'success' + + delegate :income_wallet, to: :order + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id, address: order.income_account_emoney) + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = transaction_for(invoice) + return if transaction.nil? + + invoice.update_invoice_details(transaction: transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def transaction_for(invoice) + transactions = collect_transactions_on(address: invoice.address) + raw_transaction = transactions.find(&method(:match_transaction?)) + Transaction.build_from(raw_transaction: raw_transaction) if raw_transaction + end + + def collect_transactions_on(address:) + response = client.income_transactions(address) + response_status = response['status'] + raise TransactionsHistoryRequestFailed, response.to_s unless response_status == RESPONSE_SUCCESS_STATUS + + response['data']['txs'] + end + + def match_transaction?(transaction) + transaction_created_at = Time.at(transaction['time']).to_datetime.utc + invoice_created_at = invoice.created_at.utc + amount = parse_amount(transaction) + + match_timing?(invoice_created_at, transaction_created_at) && match_amount?(amount) + end + + def match_timing?(invoice_created_at, transaction_created_at) + invoice_created_at < transaction_created_at + end + + def match_amount?(amount) + amount.to_d == invoice.amount.to_d + end + + def parse_amount(transaction) + received = transaction['amounts_received'].find { |received| received['recipient'] == invoice.address } + received ? received['amount'] : 0 + end + + def client + @client ||= Client.new(api_key: api_key) + end + end +end diff --git a/lib/payment_services/block_io/payout.rb b/lib/payment_services/block_io/payout.rb new file mode 100644 index 00000000..bc7d9e44 --- /dev/null +++ b/lib/payment_services/block_io/payout.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class PaymentServices::BlockIo + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + self.table_name = 'block_io_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :address, :fee, :state, presence: true + + alias_attribute :txid, :transaction_id + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + end + state :completed + state :failed + end + + def pay(transaction_id:) + update(transaction_id: transaction_id) + end + + def order_payout + @order_payout ||= OrderPayout.find(order_payout_id) + end + + def update_payout_details!(transaction:) + update!( + transaction_created_at: transaction.created_at, + fee: transaction.total_spend - amount.to_f + ) + confirm! if transaction.successful? + end + end +end diff --git a/lib/payment_services/block_io/payout_adapter.rb b/lib/payment_services/block_io/payout_adapter.rb index 5e339f16..1711de89 100644 --- a/lib/payment_services/block_io/payout_adapter.rb +++ b/lib/payment_services/block_io/payout_adapter.rb @@ -3,29 +3,91 @@ # Copyright (c) 2020 FINFEX https://github.com/finfex require_relative 'client' +require_relative 'payout' +require_relative 'transaction' # Сервис выплаты на BlockIo. Выполняет запрос на BlockIo-Клиент. # class PaymentServices::BlockIo class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter MIN_PAYOUT_AMOUNT = 0.00002 # Block.io restriction - ALLOWED_CURRENCIES = %w(BTC LTC).freeze + ALLOWED_CURRENCIES = %w(btc ltc doge).freeze + DEFAULT_FEE_PRIORITY = 'medium' + BTC_FEE_PRIORITY = 'medium' + Error = Class.new StandardError + TansactionIdNotReceived = Class.new Error - def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:) - raise "Можно делать выплаты только в #{ALLOWED_CURRENCIES.join(', ')}" unless ALLOWED_CURRENCIES.include?(amount.currency.to_s) - raise "Кошелек должен быть в #{ALLOWED_CURRENCIES.join(', ')}" unless ALLOWED_CURRENCIES.include?(wallet.currency.to_s) - raise 'Валюты должны совпадать' unless amount.currency.to_s == wallet.currency.to_s + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + amount_currency = amount.currency.to_s.downcase + raise "Можно делать выплаты только в #{ALLOWED_CURRENCIES.join(', ')}" unless ALLOWED_CURRENCIES.include?(amount_currency) + raise "Кошелек должен быть в #{ALLOWED_CURRENCIES.join(', ')}" unless ALLOWED_CURRENCIES.include?(wallet_currency) + raise 'Валюты должны совпадать' unless amount_currency == wallet_currency raise "Минимальная выплата #{MIN_PAYOUT_AMOUNT}, к выплате #{amount}" if amount.to_f < MIN_PAYOUT_AMOUNT - super + make_payout( + amount: amount, + payment_card_details: payment_card_details, + transaction_id: transaction_id, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? || payout.transaction_id.nil? + + transaction = build_transaction(payout) + payout.update_payout_details!(transaction: transaction) + transaction + end + + def payout + @payout ||= Payout.find_by(id: payout_id) end private - # rubocop:disable Lint/UnusedMethodArgument - def make_payout(amount:, payment_card_details:, transaction_id:, destination_account:) - # rubocop:enable Lint/UnusedMethodArgument - client = Client.new(api_key: wallet.api_key, pin: wallet.api_secret) - client.make_payout(address: destination_account, amount: amount.format(decimal_mark: '.', symbol: nil), nonce: transaction_id) + attr_accessor :payout_id + + def make_payout(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + payout = create_payout!(amount: amount, address: destination_account, order_payout_id: order_payout_id) + response = client.make_payout( + address: destination_account, + amount: amount.format(decimal_mark: '.', symbol: nil, thousands_separator: ''), + nonce: transaction_id, + fee_priority: fee_priority + ) + transaction_id = client.extract_transaction_id(response) + raise TansactionIdNotReceived, response.to_s unless transaction_id + + payout.pay!(transaction_id: transaction_id) + end + + def find_transaction(txid:, transactions:) + transactions.find { |transaction| transaction['txid'] == txid } + end + + def create_payout!(amount:, address:, order_payout_id:) + Payout.create!(amount: amount, address: address, order_payout_id: order_payout_id) + end + + def build_transaction(payout) + wallet_transactions = client.outcome_transactions(address: wallet.account)['data']['txs'] + raw_transaction = find_transaction(txid: payout.transaction_id, transactions: wallet_transactions) + + Transaction.build_from(raw_transaction: raw_transaction.merge('currency' => payout.amount_currency.downcase)) + end + + def fee_priority + DEFAULT_FEE_PRIORITY + end + + def wallet_currency + @wallet_currency ||= wallet.currency.to_s.downcase.inquiry + end + + def client + @client ||= Client.new(api_key: api_key, pin: api_secret) end end end diff --git a/lib/payment_services/block_io/transaction.rb b/lib/payment_services/block_io/transaction.rb new file mode 100644 index 00000000..668df1e3 --- /dev/null +++ b/lib/payment_services/block_io/transaction.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class PaymentServices::BlockIo + class Transaction + include Virtus.model + + CONFIRMATIONS_FOR_COMPLETE = 1 + + attribute :id, String + attribute :confirmations, Integer + attribute :source, String + + def self.build_from(raw_transaction:) + new( + id: raw_transaction['txid'], + confirmations: raw_transaction['confirmations'], + source: raw_transaction + ) + end + + def to_s + source.to_s + end + + def successful? + currency_btc? || confirmations >= CONFIRMATIONS_FOR_COMPLETE + end + + def created_at + Time.at(source['time']).to_datetime.utc + end + + def total_spend + source['total_amount_sent'].to_f + end + + private + + def currency_btc? + source['currency'] == 'btc' + end + end +end diff --git a/lib/payment_services/blockchair.rb b/lib/payment_services/blockchair.rb new file mode 100644 index 00000000..3e4e1be9 --- /dev/null +++ b/lib/payment_services/blockchair.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class Blockchair < Base + autoload :Invoicer, 'payment_services/blockchair/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/blockchair/blockchain.rb b/lib/payment_services/blockchair/blockchain.rb new file mode 100644 index 00000000..b5a9d1c8 --- /dev/null +++ b/lib/payment_services/blockchair/blockchain.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +class PaymentServices::Blockchair + class Blockchain + API_URL = 'https://api.blockchair.com' + CURRENCY_TO_BLOCKCHAIN = { + btc: 'bitcoin', + bch: 'bitcoin_cash', + ltc: 'litecoin', + doge: 'dogecoin', + dsh: 'dash', + zec: 'zcash', + eth: 'ethereum', + ada: 'cardano', + xlm: 'stellar', + xrp: 'ripple', + eos: 'eos', + usdt: 'erc_20' + }.freeze + USDT_ERC_CONTRACT_ADDRESS = '0xdac17f958d2ee523a2206206994597c13d831ec7' + BLOCKCHAIN_TO_AMOUNT_DIVIDER = { + 'ethereum' => 1e+18, + 'cardano' => 1e+6, + 'ripple' => 1e+6, + 'erc_20' => 1e+6, + }.freeze + DEFAULT_AMOUNT_DIVIDER = 1e+8 + + delegate :ethereum?, :cardano?, :stellar?, :ripple?, :eos?, :erc_20?, to: :blockchain + + def initialize(currency:) + @currency = currency + end + + def name + blockchain + end + + def transactions_endpoint(address) + if cardano? + "#{blockchain_base_api}/raw/address/#{address}" + elsif stellar? + "#{raw_account_base_url(address)}?payments=true&account=false" + elsif ripple? + "#{raw_account_base_url(address)}?transactions=true" + elsif eos? + "#{raw_account_base_url(address)}?actions=true" + elsif erc_20? + "#{API_URL}/ethereum/erc-20/#{USDT_ERC_CONTRACT_ADDRESS}/dashboards/address/#{address}" + else + "#{blockchain_base_api}/dashboards/address/#{address}" + end + end + + def transactions_data_endpoint(tx_ids) + "#{blockchain_base_api}/dashboards/transactions/#{tx_ids.join(',')}" + end + + def amount_divider + BLOCKCHAIN_TO_AMOUNT_DIVIDER[blockchain] || DEFAULT_AMOUNT_DIVIDER + end + + private + + attr_reader :currency + + def blockchain + @blockchain ||= CURRENCY_TO_BLOCKCHAIN[currency.to_sym].inquiry + end + + def blockchain_base_api + "#{API_URL}/#{blockchain_url}" + end + + def raw_account_base_url(address) + "#{blockchain_base_api}/raw/account/#{address}" + end + + def blockchain_url + blockchain.bitcoin_cash? ? 'bitcoin-cash' : blockchain + end + end +end diff --git a/lib/payment_services/blockchair/client.rb b/lib/payment_services/blockchair/client.rb new file mode 100644 index 00000000..277ab51f --- /dev/null +++ b/lib/payment_services/blockchair/client.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative 'blockchain' + +class PaymentServices::Blockchair + class Client < ::PaymentServices::Base::Client + def initialize(api_key:, currency:) + @api_key = api_key + @blockchain = Blockchain.new(currency: currency) + end + + def transactions(address:) + safely_parse http_request( + url: "#{blockchain.transactions_endpoint(address)}#{api_suffix}", + method: :GET, + headers: build_headers + ) + end + + def transactions_data(tx_ids:) + safely_parse http_request( + url: "#{blockchain.transactions_data_endpoint(tx_ids)}#{api_suffix}", + method: :GET, + headers: build_headers + ) + end + + private + + attr_reader :api_key, :blockchain + + def api_suffix + api_key ? "?key=#{api_key}" : '' + end + + def build_headers + { + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache' + } + end + end +end diff --git a/lib/payment_services/blockchair/invoice.rb b/lib/payment_services/blockchair/invoice.rb new file mode 100644 index 00000000..9da7f328 --- /dev/null +++ b/lib/payment_services/blockchair/invoice.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class PaymentServices::Blockchair + class Invoice < ::PaymentServices::Base::CryptoInvoice + self.table_name = 'blockchair_invoices' + + monetize :amount_cents, as: :amount + + def memo + @memo ||= order.income_wallet.memo + end + + def update_invoice_details(transaction:) + bind_transaction! if pending? + update!(transaction_created_at: transaction.created_at, transaction_id: transaction.id) + + pay!(payload: transaction) if transaction.successful? + end + end +end diff --git a/lib/payment_services/blockchair/invoicer.rb b/lib/payment_services/blockchair/invoicer.rb new file mode 100644 index 00000000..9d840c6a --- /dev/null +++ b/lib/payment_services/blockchair/invoicer.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' +require_relative 'blockchain' +require_relative 'transaction_matcher' + +class PaymentServices::Blockchair + class Invoicer < ::PaymentServices::Base::Invoicer + TRANSANSACTIONS_AMOUNT_TO_CHECK = 3 + TransactionsHistoryRequestFailed = Class.new StandardError + + delegate :income_wallet, to: :order + delegate :currency, to: :income_wallet + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id, address: order.income_account_emoney) + end + + def update_invoice_state! + transaction = transaction_for(invoice) + return if transaction.nil? + + invoice.update_invoice_details(transaction: transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + def async_invoice_state_updater? + true + end + + def transaction_for(invoice) + TransactionMatcher.new(invoice: invoice, transactions: collect_transactions).perform + end + + private + + def collect_transactions + if blockchain.ethereum? + blockchair_transactions_by_address(invoice.address)['calls'] + elsif blockchain.cardano? + blockchair_transactions_by_address(invoice.address)['address']['caTxList'] + elsif blockchain.stellar? + blockchair_transactions_by_address(invoice.address)['payments'] + elsif blockchain.ripple? + blockchair_transactions_by_address(invoice.address)['transactions']['transactions'] + elsif blockchain.eos? + blockchair_transactions_by_address(invoice.address)['actions'] + elsif blockchain.erc_20? + blockchair_transactions_by_address(invoice.address.downcase)['transactions'] + else + transactions_data_for_address(invoice.address) + end + end + + def blockchair_transactions_by_address(address) + transactions = client.transactions(address: address)['data'] + raise TransactionsHistoryRequestFailed, 'Check the payment address' unless transactions + + transactions[address] + end + + def transactions_data_for_address(address) + transaction_ids_on_wallet = blockchair_transactions_by_address(address)['transactions'] + client.transactions_data(tx_ids: transaction_ids_on_wallet.first(TRANSANSACTIONS_AMOUNT_TO_CHECK))['data'] + end + + def blockchain + @blockchain ||= Blockchain.new(currency: currency.to_s.downcase) + end + + def client + @client ||= Client.new(api_key: api_key, currency: currency.to_s.downcase) + end + end +end diff --git a/lib/payment_services/blockchair/transaction.rb b/lib/payment_services/blockchair/transaction.rb new file mode 100644 index 00000000..33302313 --- /dev/null +++ b/lib/payment_services/blockchair/transaction.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +class PaymentServices::Blockchair + class Transaction + include Virtus.model + + attribute :id, String + attribute :created_at, DateTime + attribute :blockchain, String + attribute :source, Hash + + RIPPLE_SUCCESS_STATUS = 'tesSUCCESS' + + def self.build_from(raw_transaction:) + new( + id: raw_transaction[:transaction_hash], + created_at: raw_transaction[:created_at], + blockchain: raw_transaction[:blockchain].name, + source: raw_transaction[:source].deep_symbolize_keys + ) + end + + def to_s + source.to_s + end + + def successful? + send("#{blockchain}_transaction_succeed?") + end + + def sender_address + send("#{blockchain}_sender_address") + end + + private + + def method_missing(method_name) + super unless method_name.end_with?('_transaction_succeed?') + + generic_transaction_succeed? + end + + def generic_transaction_succeed? + source.key?(:block_id) && source[:block_id].positive? + end + + def cardano_transaction_succeed? + source.key?(:ctbFees) + end + + def ripple_transaction_succeed? + source.dig(:meta, :TransactionResult) == RIPPLE_SUCCESS_STATUS + end + + def stellar_transaction_succeed? + source[:transaction_successful] + end + + def eos_transaction_succeed? + source.key?(:block_num) && source[:block_num].positive? + end + + def bitcoin_sender_address + source.dig(:input, :recipient) + end + + def bitcoin_cash_sender_address + source.dig(:input, :recipient) + end + + def litecoin_sender_address + source.dig(:input, :recipient) + end + + def dogecoin_sender_address + source.dig(:input, :recipient) + end + + def dash_sender_address + source.dig(:input, :recipient) + end + + def zcash_sender_address + source.dig(:input, :recipient) + end + + def ethereum_sender_address + source[:sender] + end + + def cardano_sender_address + source.dig(:input, :ctaAddress, :unCAddress) + end + + def stellar_sender_address + source[:from] + end + + def ripple_sender_address + source.dig(:TakerGets, :issuer) + end + + def eos_sender_address + source[:from] + end + + def erc_20_sender_address + source[:sender] + end + end +end diff --git a/lib/payment_services/blockchair/transaction_matcher.rb b/lib/payment_services/blockchair/transaction_matcher.rb new file mode 100644 index 00000000..db882122 --- /dev/null +++ b/lib/payment_services/blockchair/transaction_matcher.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require_relative 'transaction' +require_relative 'blockchain' + +class PaymentServices::Blockchair + class TransactionMatcher + RIPPLE_AFTER_UNIX_EPOCH = 946684800 + + def initialize(invoice:, transactions:) + @invoice = invoice + @transactions = transactions + end + + def perform + send("match_#{blockchain.name}_transaction") + end + + private + + attr_reader :invoice, :transactions + + delegate :created_at, :memo, to: :invoice, prefix: true + delegate :amount_divider, to: :blockchain + + def blockchain + @blockchain ||= Blockchain.new(currency: invoice.order.income_wallet.currency.to_s.downcase) + end + + def build_transaction(id:, created_at:, blockchain:, source:) + Transaction.build_from(raw_transaction: { transaction_hash: id, created_at: created_at, blockchain: blockchain, source: source }) + end + + def match_cardano_transaction + raw_transaction = transactions.find { |transaction| match_cardano_transaction?(transaction) } + return unless raw_transaction + + inputs = raw_transaction['ctbInputs'] + output = raw_transaction['ctbOutputs'].find { |output| output.match_by_output? } + build_transaction( + id: raw_transaction['ctbId'], + created_at: timestamp_in_utc(raw_transaction['ctbTimeIssued']), + blockchain: blockchain, + source: raw_transaction.merge(input: most_similar_cardano_input_by(output: output, inputs: inputs)) + ) + end + + def match_stellar_transaction + raw_transaction = transactions.find { |transaction| match_stellar_transaction?(transaction) } + return unless raw_transaction + + build_transaction( + id: raw_transaction['transaction_hash'], + created_at: datetime_string_in_utc(raw_transaction['created_at']), + blockchain: blockchain, + source: raw_transaction + ) + end + + def match_ripple_transaction + raw_transaction = transactions.find { |transaction| match_ripple_transaction?(transaction) } + return unless raw_transaction + + build_transaction( + id: raw_transaction['tx']['hash'], + created_at: build_ripple_time(raw_transaction['tx']['date']), + blockchain: blockchain, + source: raw_transaction + ) + end + + def match_eos_transaction + raw_transaction = transactions.find { |transaction| match_eos_transaction?(transaction) } + build_transaction(id: raw_transaction['trx_id'], created_at: datetime_string_in_utc(raw_transaction['block_time']), blockchain: blockchain, source: raw_transaction) if raw_transaction + end + + def method_missing(method_name) + super unless method_name.start_with?('match_') && method_name.end_with?('_transaction') + + raw_transaction = transactions_data.find { |transaction| match_generic_transaction?(transaction) } + return unless raw_transaction + + build_transaction( + id: raw_transaction['transaction_hash'], + created_at: datetime_string_in_utc(raw_transaction['time']), + blockchain: blockchain, + source: raw_transaction.merge(input: most_similar_input_by(output: raw_transaction)) + ) + end + + def match_cardano_transaction?(transaction) + transaction_created_at = timestamp_in_utc(transaction['ctbTimeIssued']) + + invoice_created_at.utc < transaction_created_at && transaction['ctbOutputs'].any?(&method(:match_by_output?)) + end + + def match_stellar_transaction?(transaction) + amount = transaction['amount'] + transaction_created_at = datetime_string_in_utc(transaction['created_at']) + + invoice_created_at.utc < transaction_created_at && match_amount?(amount) + end + + def match_generic_transaction?(transaction) + amount = transaction['value'].to_f / amount_divider + transaction_created_at = datetime_string_in_utc(transaction['time']) + + invoice_created_at.utc < transaction_created_at && match_amount?(amount) + end + + def match_ripple_transaction?(transaction) + transaction_info = transaction['tx'] + amount = transaction_info['Amount'].to_f / amount_divider + transaction_created_at = build_ripple_time(transaction_info['date']) + + invoice_created_at.utc < transaction_created_at && match_amount?(amount) && match_tag?(transaction_info['DestinationTag']) + end + + def match_tag?(tag) + invoice_memo.present? ? invoice_memo == tag.to_s : true + end + + def match_eos_transaction?(transaction) + transaction_created_at = datetime_string_in_utc(transaction['block_time']) + amount_data = transaction['action_trace']['act']['data'] + invoice_created_at.utc < transaction_created_at && match_eos_amount?(amount_data) + end + + def match_eos_amount?(amount_data) + amount, currency = amount_data['quantity'].split + match_amount?(amount) && currency == 'EOS' && match_tag?(amount_data['memo']) + end + + def match_by_output?(output) + amount = output['ctaAmount']['getCoin'].to_f / amount_divider + match_amount?(amount) && output['ctaAddress'] == invoice.address + end + + def match_amount?(received_amount) + received_amount.to_d == invoice.amount.to_d + end + + def datetime_string_in_utc(datetime_string) + DateTime.parse(datetime_string).utc + end + + def timestamp_in_utc(timestamp) + Time.at(timestamp).to_datetime.utc + end + + def build_ripple_time(timestamp) + timestamp_in_utc(timestamp + RIPPLE_AFTER_UNIX_EPOCH) + end + + def transactions_data(direction: 'outputs') + signals = [] + + transactions.each do |_transaction_id, transaction| + signals << transaction[direction] + end + + signals.flatten + end + + def most_similar_input_by(output:) + inputs = transactions_data(direction: 'inputs').select { |input| input['spending_time'] == output['time'] } + inputs.min_by { |input| (input['value'] - output['value']).abs } + end + + def most_similar_cardano_input_by(output:, inputs:) + inputs.min_by { |input| (input['ctaAmount']['getCoin'].to_f - output['ctaAmount']['getCoin'].to_f).abs } + end + end +end diff --git a/lib/payment_services/bovapay.rb b/lib/payment_services/bovapay.rb new file mode 100644 index 00000000..dbebe223 --- /dev/null +++ b/lib/payment_services/bovapay.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class Bovapay < Base + autoload :Invoicer, 'payment_services/bovapay/invoicer' + autoload :PayoutAdapter, 'payment_services/bovapay/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/bovapay/client.rb b/lib/payment_services/bovapay/client.rb new file mode 100644 index 00000000..ce411baf --- /dev/null +++ b/lib/payment_services/bovapay/client.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class PaymentServices::Bovapay + class Client < ::PaymentServices::Base::Client + API_URL = 'https://bovatech.cc/v1' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + params.merge!(user_uuid: api_key) + safely_parse http_request( + url: "#{API_URL}/p2p_transactions", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def invoice(deposit_id:) + safely_parse http_request( + url: "#{API_URL}/p2p_transactions/#{deposit_id}", + method: :GET, + headers: {} + ) + end + + def create_payout(params:) + params.merge!(user_uuid: api_key) + safely_parse http_request( + url: "#{API_URL}/mass_transactions", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def payout(withdrawal_id:) + safely_parse http_request( + url: "#{API_URL}/mass_transactions/#{withdrawal_id}", + method: :GET, + headers: {} + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers(signature:) + { + 'Content-Type' => 'application/json', + 'Signature' => signature + } + end + + def build_signature(params) + Digest::SHA1.hexdigest("#{secret_key}#{params.to_json}") + end + end +end diff --git a/lib/payment_services/bovapay/invoice.rb b/lib/payment_services/bovapay/invoice.rb new file mode 100644 index 00000000..cbe61a2b --- /dev/null +++ b/lib/payment_services/bovapay/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::Bovapay + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'successed' + FAILED_PROVIDER_STATE = 'failed' + + self.table_name = 'bovapay_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/bovapay/invoicer.rb b/lib/payment_services/bovapay/invoicer.rb new file mode 100644 index 00000000..d0b77dc6 --- /dev/null +++ b/lib/payment_services/bovapay/invoicer.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Bovapay + class Invoicer < ::PaymentServices::Base::Invoicer + Error = Class.new StandardError + PAYEER_TYPE = 'ftd' + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_invoice(params: invoice_params) + raise Error, "Can't create invoice: #{response['errors']}" if response['errors'].present? + + invoice.update!(deposit_id: response.dig('payload', 'id')) + card = response.dig('payload', 'resipient_card') + PaymentServices::Base::Wallet.new( + address: card['number'], + name: card['card_holder'], + memo: card['bank_full_name'] + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.invoice(deposit_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction.dig('payload', 'state')) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :card_bank, to: :bank_resolver + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_params + { + amount: invoice.amount.to_i, + merchant_id: order.public_id.to_s, + payeer_identifier: "#{Rails.env}_user_id_#{order.user_id}", + payeer_ip: order.remote_ip, + payeer_card_number: order.income_account, + payeer_type: PAYEER_TYPE, + lifetime: order.income_payment_timeout.to_i, + redirect_url: order.success_redirect, + callback_url: "#{routes_helper.public_public_callbacks_api_root_url}/v1/appex_money/confirm_payout", + payment_method: 'card', + currency: invoice.amount_currency.to_s.downcase, + bank_name: card_bank, + } + end + + def bank_resolver + @bank_resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/bovapay/payout.rb b/lib/payment_services/bovapay/payout.rb new file mode 100644 index 00000000..e6dd0221 --- /dev/null +++ b/lib/payment_services/bovapay/payout.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::Bovapay + class Payout < ::PaymentServices::Base::FiatPayout + SUCCESS_PROVIDER_STATE = 'paid' + FAILED_PROVIDER_STATE = 'failed' + + self.table_name = 'bovapay_payouts' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/bovapay/payout_adapter.rb b/lib/payment_services/bovapay/payout_adapter.rb new file mode 100644 index 00000000..54d74b4c --- /dev/null +++ b/lib/payment_services/bovapay/payout_adapter.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::Bovapay + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + Error = Class.new StandardError + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + transaction = client.payout(withdrawal_id: payout.withdrawal_id) + payout.update_state_by_provider(transaction.dig('payload', 'state')) + transaction + end + + private + + delegate :sbp_bank, :sbp?, to: :bank_resolver + + attr_reader :payout + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + response = client.create_payout(params: payout_params) + raise Error, "Can't create invoice: #{response['errors']}" if response['errors'].present? + + payout.pay!(withdrawal_id: response.dig('payload', 'id')) + end + + def payout_params + order = OrderPayout.find(payout.order_payout_id).order + params = { + to_card: payout.destination_account, + amount: payout.amount.to_i, + callback_url: "#{Rails.application.routes.url_helpers.public_public_callbacks_api_root_url}/v1/appex_money/confirm_payout", + merchant_id: "#{order.public_id}-#{payout.order_payout_id}", + currency: payout.amount_currency.to_s.downcase, + payment_method: sbp? ? 'sbp' : 'card', + lifetime: 3600 + } + params[:sbp_bank_name] = sbp_bank if sbp? + params + end + + def bank_resolver + @bank_resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/bridgex.rb b/lib/payment_services/bridgex.rb new file mode 100644 index 00000000..602906ca --- /dev/null +++ b/lib/payment_services/bridgex.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class Bridgex < Base + autoload :Invoicer, 'payment_services/bridgex/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/bridgex/client.rb b/lib/payment_services/bridgex/client.rb new file mode 100644 index 00000000..1ae4ff1c --- /dev/null +++ b/lib/payment_services/bridgex/client.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class PaymentServices::Bridgex + class Client < ::PaymentServices::Base::Client + API_URL = 'https://p2p-api.bridgex.ai/v1' + TEST_MODE = 'no' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + params.merge!(project: api_key, test_mode: TEST_MODE) + safely_parse http_request( + url: "#{API_URL}/payment/create", + method: :POST, + body: params.merge(sign: build_signature(params)).to_json, + headers: build_headers + ) + end + + def transaction(deposit_id:) + params = { project: api_key, order_id: deposit_id, test_mode: TEST_MODE } + safely_parse http_request( + url: "#{API_URL}/payment/status", + method: :POST, + body: params.merge(sign: build_signature(params)).to_json, + headers: build_headers + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_signature(params) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret_key, params.to_json) + end + + def build_headers + { + 'Content-Type' => 'application/json' + } + end + end +end diff --git a/lib/payment_services/bridgex/invoice.rb b/lib/payment_services/bridgex/invoice.rb new file mode 100644 index 00000000..d58598a0 --- /dev/null +++ b/lib/payment_services/bridgex/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::Bridgex + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'paid' + FAILED_PROVIDER_STATE = 'cancel' + + self.table_name = 'bridgex_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/bridgex/invoicer.rb b/lib/payment_services/bridgex/invoicer.rb new file mode 100644 index 00000000..264b4499 --- /dev/null +++ b/lib/payment_services/bridgex/invoicer.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Bridgex + class Invoicer < ::PaymentServices::Base::Invoicer + Error = Class.new StandardError + PAYMENT_TIMEOUT_IN_SECONDS = 1800 + UNUSED_BANK_PARAM = 'unused_param' + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_invoice(params: invoice_params) + + raise Error, "Can't create invoice: #{response}" if response['message'] + + invoice.update!( + deposit_id: order.public_id.to_s, + pay_url: response.dig('result', 'url') + ) + end + + def pay_invoice_url + invoice.present? ? URI.parse(invoice.reload.pay_url) : '' + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.transaction(deposit_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction.dig('result', 'payment_status')) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :card_bank, :sbp_bank, :sbp?, to: :resolver + delegate :require_income_card_verification, to: :income_payment_system + delegate :income_unk, :income_payment_system, to: :order + + def invoice_params + params = { + amount: invoice.amount.to_i, + order: order.public_id.to_s, + customer_ip: order.remote_ip, + customer_ident: '1', + card: card_payment?, + sbp: sbp_payment?, + qr: 'no', + ttl: PAYMENT_TIMEOUT_IN_SECONDS + } + params[:category] = 17 if !require_income_card_verification + params[:bank] = card_bank if !sbp? && card_bank != UNUSED_BANK_PARAM + params[:bank] = sbp_bank if sbp? && sbp_bank.present? + params + end + + def resolver + @resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def sbp_payment? + sbp? ? 'yes' : 'no' + end + + def card_payment? + sbp? ? 'no' : 'yes' + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/coin_payments_hub.rb b/lib/payment_services/coin_payments_hub.rb new file mode 100644 index 00000000..52563631 --- /dev/null +++ b/lib/payment_services/coin_payments_hub.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class CoinPaymentsHub < Base + autoload :Invoicer, 'payment_services/coin_payments_hub/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/coin_payments_hub/client.rb b/lib/payment_services/coin_payments_hub/client.rb new file mode 100644 index 00000000..f41d58e6 --- /dev/null +++ b/lib/payment_services/coin_payments_hub/client.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'openssl' +require 'digest' +require 'base64' + +class PaymentServices::CoinPaymentsHub + class Client < ::PaymentServices::Base::Client + API_URL = 'https://api.coinpaymentshub.com/v1' + PROJECT_UUID = '039e74d1-ef06-483d-b64d-1fa56334aa65' + + def initialize(api_key:) + @api_key = api_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/invoice/create", + method: :POST, + body: sign_params(params), + headers: build_headers + ) + end + + def transaction(deposit_id:) + params = { order_id: deposit_id } + safely_parse http_request( + url: "#{API_URL}/invoice/status", + method: :POST, + body: sign_params(params), + headers: build_headers + ) + end + + private + + attr_reader :api_key + + def build_headers + { + 'Content-Type' => 'application/json', + 'project' => PROJECT_UUID + } + end + + def sign_params(params) + md5_api_key = Digest::MD5.hexdigest(api_key) + cipher = OpenSSL::Cipher.new("aes-256-cbc") + cipher.encrypt + iv = cipher.iv = cipher.random_iv + cipher.key = md5_api_key + + value = cipher.update(params.to_json) + cipher.final + value = Base64.encode64(value).chomp + iv = Base64.encode64(iv).chomp + mac = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), md5_api_key, iv + value) + tag = '' + + json_string = { iv: iv, value: value, mac: mac, tag: tag }.to_json + Base64.encode64(json_string).gsub(/\n/, '') + end + end +end diff --git a/lib/payment_services/coin_payments_hub/currency_repository.rb b/lib/payment_services/coin_payments_hub/currency_repository.rb new file mode 100644 index 00000000..06dd3e40 --- /dev/null +++ b/lib/payment_services/coin_payments_hub/currency_repository.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class PaymentServices::CoinPaymentsHub + class CurrencyRepository + TOKEN_NETWORK_TO_PROVIDER_TOKEN_ID = { + erc20: 'd08addf2-8af2-4bc0-9a4e-880fced2f0a0', + trc20: '2e0dc850-4b02-49a2-a1ae-6a3ea1daf344' + }.stringify_keys.freeze + TOKEN_NETWORK_TO_PROVIDER_NETWORK_ID = { + erc20: '808977fc-9a72-4725-b723-bde4c995dba4', + trc20: '51d1d35b-8d73-4384-aa7d-fad09de2c1dc' + }.stringify_keys.freeze + Error = Class.new StandardError + + include Virtus.model + + attribute :token_network, String + + def self.build_from(token_network:) + new(token_network: token_network) + end + + def provider_token + TOKEN_NETWORK_TO_PROVIDER_TOKEN_ID[token_network] || raise_token_network_invalid! + end + + def provider_network + TOKEN_NETWORK_TO_PROVIDER_NETWORK_ID[token_network] || raise_token_network_invalid! + end + + private + + def raise_token_network_invalid! + raise Error, 'Token network invalid' + end + end +end diff --git a/lib/payment_services/coin_payments_hub/invoice.rb b/lib/payment_services/coin_payments_hub/invoice.rb new file mode 100644 index 00000000..ff559978 --- /dev/null +++ b/lib/payment_services/coin_payments_hub/invoice.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class PaymentServices::CoinPaymentsHub + class Invoice < ::PaymentServices::Base::CryptoInvoice + INITIAL_PROVIDER_STATE = 'wait' + Error = Class.new StandardError + + self.table_name = 'coin_payments_hub_invoices' + + monetize :amount_cents, as: :amount + + def update_state_by_transaction!(transaction) + validate_transaction_amount!(transaction: transaction) + + bind_transaction! if pending? + update!(provider_state: transaction.status) + pay!(payload: transaction) if transaction.succeed? + cancel! if transaction.failed? + end + + def transaction_created_at + nil + end + + private + + delegate :income_payment_system, to: :order + delegate :token_network, to: :income_payment_system + + def validate_transaction_amount!(transaction:) + raise Error, "#{amount.to_f} #{amount_currency} is needed. But #{transaction.amount} #{transaction.currency} has come." unless transaction.valid_amount?(amount.to_f, amount_currency) + end + end +end diff --git a/lib/payment_services/coin_payments_hub/invoicer.rb b/lib/payment_services/coin_payments_hub/invoicer.rb new file mode 100644 index 00000000..a9a63008 --- /dev/null +++ b/lib/payment_services/coin_payments_hub/invoicer.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' +require_relative 'transaction' +require_relative 'currency_repository' + +class PaymentServices::CoinPaymentsHub + class Invoicer < ::PaymentServices::Base::Invoicer + PROVIDER_SUCCESS_STATE = 'ok' + Error = Class.new StandardError + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_invoice(params: invoice_params) + validate_response!(response) + + create_temp_kassa_wallet(address: response.dig('result', 'address')) + invoice.update!(deposit_id: order.public_id.to_s) + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + response = client.transaction(deposit_id: invoice.deposit_id) + validate_response!(response) + + raw_transaction = response['result'] + transaction = Transaction.build_from(raw_transaction) + invoice.update_state_by_transaction!(transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :income_payment_system, to: :order + delegate :withdrawal_wallet, to: :income_payment_system + delegate :token_network, to: :income_payment_system + delegate :wallets, to: :income_payment_system + + def invoice_params + { + amount: invoice.amount.to_f, + network: CurrencyRepository.build_from(token_network: token_network).provider_network, + token: CurrencyRepository.build_from(token_network: token_network).provider_token, + withdrawal_wallet: withdrawal_wallet, + order_id: order.public_id.to_s, + ttl: PreliminaryOrder::MAX_LIVE.to_i, + is_client_repeat_wallet: false + } + end + + def create_temp_kassa_wallet(address:) + wallet = wallets.find_or_create_by(account: address) + order.update(income_wallet_id: wallet.id) + end + + def validate_response!(response) + return if response['state'] == PROVIDER_SUCCESS_STATE + + raise Error, "Can't create invoice: #{response.dig('result', 'error')}" + end + + def client + @client ||= Client.new(api_key: api_key) + end + end +end diff --git a/lib/payment_services/coin_payments_hub/transaction.rb b/lib/payment_services/coin_payments_hub/transaction.rb new file mode 100644 index 00000000..5790155e --- /dev/null +++ b/lib/payment_services/coin_payments_hub/transaction.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class PaymentServices::CoinPaymentsHub + class Transaction + SUCCESS_PROVIDER_STATE = 'success' + FAILED_PROVIDER_STATE = 'cancel' + + include Virtus.model + + attribute :amount, Float + attribute :currency, String + attribute :status, String + attribute :source, Hash + + def self.build_from(raw_transaction) + new( + amount: raw_transaction['paid_amount'].to_f, + currency: raw_transaction['currency_symbol'], + status: raw_transaction['status'], + source: raw_transaction + ) + end + + def to_s + source.to_s + end + + def valid_amount?(payout_amount, payout_currency) + (amount.zero? || amount == payout_amount) && currency == payout_currency + end + + def succeed? + status == SUCCESS_PROVIDER_STATE + end + + def failed? + status == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/crypto_apis.rb b/lib/payment_services/crypto_apis.rb index e9275d7e..35ec2f86 100644 --- a/lib/payment_services/crypto_apis.rb +++ b/lib/payment_services/crypto_apis.rb @@ -5,6 +5,8 @@ module PaymentServices class CryptoApis < Base autoload :Invoicer, 'payment_services/crypto_apis/invoicer' + autoload :PayoutAdapter, 'payment_services/crypto_apis/payout_adapter' register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter end end diff --git a/lib/payment_services/crypto_apis/client.rb b/lib/payment_services/crypto_apis/client.rb index c23f535d..a3df2e1e 100644 --- a/lib/payment_services/crypto_apis/client.rb +++ b/lib/payment_services/crypto_apis/client.rb @@ -1,79 +1,41 @@ # frozen_string_literal: true -# Copyright (c) 2020 FINFEX https://github.com/finfex - -class PaymentServices::CryptoApis - class Client - include AutoLogger - TIMEOUT = 10 - API_URL = 'https://api.cryptoapis.io/v1' - - def initialize(api_key) - @api_key = api_key - end - - def address_transactions(currency:, address:) - safely_parse http_request( - url: "#{API_URL}/bc/btc/mainnet/address/#{address}/basic/transactions", - method: :GET - ) - end - - def transaction_details(transaction_id) - safely_parse http_request( - url: "#{API_URL}/bc/btc/mainnet/txs/basic/txid/#{transaction_id}", - method: :GET - ) - end - - private - - attr_reader :api_key - - def http_request(url:, method:, body: nil) - uri = URI.parse(url) - https = http(uri) - request = build_request(uri: uri, method: method, body: body) - logger.info "Request type: #{method} to #{uri} with payload #{request.body}" - https.request(request) - end +require_relative 'clients/base_client' +require_relative 'clients/ethereum_client' +require_relative 'clients/omni_client' +require_relative 'clients/dash_client' +require_relative 'payout_clients/base_client' +require_relative 'payout_clients/ethereum_client' +require_relative 'payout_clients/omni_client' +require_relative 'payout_clients/dash_client' + +class Client + BASE_CLIENT = 'BaseClient' + + CLIENTS = { + 'eth' => 'EthereumClient', + 'etc' => 'EthereumClient', + 'omni' => 'OmniClient', + 'dsh' => 'DashClient' + } + + def initialize(currency:) + @currency = currency + end - def build_request(uri:, method:, body: nil) - request = if method == :POST - Net::HTTP::Post.new(uri.request_uri, headers) - elsif method == :GET - Net::HTTP::Get.new(uri.request_uri, headers) - else - raise "Запрос #{method} не поддерживается!" - end - request.body = (body.present? ? body : {}).to_json - request - end + def invoice + "PaymentServices::CryptoApis::Clients::#{class_name}".constantize + end - def http(uri) - Net::HTTP.start(uri.host, uri.port, - use_ssl: true, - verify_mode: OpenSSL::SSL::VERIFY_NONE, - open_timeout: TIMEOUT, - read_timeout: TIMEOUT) - end + def payout + "PaymentServices::CryptoApis::PayoutClients::#{class_name}".constantize + end - def headers - { - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': 'no-cache', - 'X-API-Key': api_key - } - end + private - def safely_parse(response) - JSON.parse(response.body).with_indifferent_access - rescue JSON::ParserError => err - logger.warn "Request failed #{response.class} #{response.body}" - Bugsnag.notify err do |report| - report.add_tab(:response, response_class: response.class, response_body: response.body) - end - response.body - end + def class_name + CLIENTS[currency] || BASE_CLIENT end + + attr_reader :currency end diff --git a/lib/payment_services/crypto_apis/clients/base_client.rb b/lib/payment_services/crypto_apis/clients/base_client.rb new file mode 100644 index 00000000..a0a70a0d --- /dev/null +++ b/lib/payment_services/crypto_apis/clients/base_client.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# Copyright (c) 2020 FINFEX https://github.com/finfex + +class PaymentServices::CryptoApis + module Clients + class BaseClient + include AutoLogger + TIMEOUT = 30 + API_URL = 'https://api.cryptoapis.io/v1' + NETWORK = 'mainnet' + + def initialize(api_key:, currency:) + @api_key = api_key + @currency = currency + end + + def address_transactions(address) + safely_parse http_request( + url: "#{base_url}/address/#{address}/basic/transactions?legacy=true", + method: :GET + ) + end + + def transaction_details(transaction_id) + safely_parse http_request( + url: "#{base_url}/txs/basic/txid/#{transaction_id}", + method: :GET + ) + end + + private + + attr_reader :api_key, :currency + + def base_url + "#{API_URL}/bc/#{currency}/#{NETWORK}" + end + + def http_request(url:, method:, body: nil) + uri = URI.parse(url) + https = http(uri) + request = build_request(uri: uri, method: method, body: body) + logger.info "Request type: #{method} to #{uri} with payload #{request.body}" + https.request(request) + end + + def build_request(uri:, method:, body: nil) + request = if method == :POST + Net::HTTP::Post.new(uri.request_uri, headers) + elsif method == :GET + Net::HTTP::Get.new(uri.request_uri, headers) + else + raise "Запрос #{method} не поддерживается!" + end + request.body = (body.present? ? body : {}).to_json + request + end + + def http(uri) + Net::HTTP.start(uri.host, uri.port, + use_ssl: true, + verify_mode: OpenSSL::SSL::VERIFY_NONE, + open_timeout: TIMEOUT, + read_timeout: TIMEOUT) + end + + def headers + { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + 'X-API-Key': api_key + } + end + + def safely_parse(response) + res = JSON.parse(response.body).with_indifferent_access + logger.info "Response: #{res}" + res + rescue JSON::ParserError => err + logger.warn "Request failed #{response.class} #{response.body}" + Bugsnag.notify err do |report| + report.add_tab(:response, response_class: response.class, response_body: response.body) + end + response.body + end + end + end +end diff --git a/lib/payment_services/crypto_apis/clients/dash_client.rb b/lib/payment_services/crypto_apis/clients/dash_client.rb new file mode 100644 index 00000000..6de4e94d --- /dev/null +++ b/lib/payment_services/crypto_apis/clients/dash_client.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative 'base_client' + +class PaymentServices::CryptoApis + module Clients + class DashClient < PaymentServices::CryptoApis::Clients::BaseClient + private + + def base_url + "#{API_URL}/bc/dash/#{NETWORK}" + end + end + end +end diff --git a/lib/payment_services/crypto_apis/clients/ethereum_client.rb b/lib/payment_services/crypto_apis/clients/ethereum_client.rb new file mode 100644 index 00000000..7775e0b5 --- /dev/null +++ b/lib/payment_services/crypto_apis/clients/ethereum_client.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require_relative 'base_client' + +class PaymentServices::CryptoApis + module Clients + class EthereumClient < PaymentServices::CryptoApis::Clients::BaseClient + def transaction_details(transaction_id) + safely_parse http_request( + url: "#{base_url}/txs/basic/hash/#{transaction_id}", + method: :GET + ) + end + end + end +end diff --git a/lib/payment_services/crypto_apis/clients/omni_client.rb b/lib/payment_services/crypto_apis/clients/omni_client.rb new file mode 100644 index 00000000..96091528 --- /dev/null +++ b/lib/payment_services/crypto_apis/clients/omni_client.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require_relative 'base_client' + +class PaymentServices::CryptoApis + module Clients + class OmniClient < PaymentServices::CryptoApis::Clients::BaseClient + private + + def base_url + "#{API_URL}/bc/btc/#{currency}/#{NETWORK}" + end + end + end +end diff --git a/lib/payment_services/crypto_apis/invoice.rb b/lib/payment_services/crypto_apis/invoice.rb index 4cd6681e..a727d96e 100644 --- a/lib/payment_services/crypto_apis/invoice.rb +++ b/lib/payment_services/crypto_apis/invoice.rb @@ -3,9 +3,9 @@ # Copyright (c) 2020 FINFEX https://github.com/finfex class PaymentServices::CryptoApis - class Invoice < ApplicationRecord - CONFIRMATIONS_FOR_COMPLETE = 2 - include Workflow + class Invoice < PaymentServices::ApplicationRecord + CONFIRMATIONS_FOR_COMPLETE = 1 + include WorkflowActiverecord self.table_name = 'crypto_apis_invoices' scope :ordered, -> { order(id: :desc) } @@ -16,13 +16,19 @@ class Invoice < ApplicationRecord workflow_column :state workflow do state :pending do + event :has_transaction, transitions_to: :with_transaction + end + state :with_transaction do + on_entry do + order.make_reserve! + end event :pay, transitions_to: :paid event :cancel, transitions_to: :cancelled end state :paid do on_entry do - order.auto_confirm!(income_amount: amount) + order.auto_confirm!(income_amount: amount, hash: transaction_id) end end state :cancelled diff --git a/lib/payment_services/crypto_apis/invoicer.rb b/lib/payment_services/crypto_apis/invoicer.rb index 0dc32c48..6eef33e4 100644 --- a/lib/payment_services/crypto_apis/invoicer.rb +++ b/lib/payment_services/crypto_apis/invoicer.rb @@ -7,6 +7,10 @@ class PaymentServices::CryptoApis class Invoicer < ::PaymentServices::Base::Invoicer + TRANSACTION_TIME_THRESHOLD = 30.minutes + ETC_TIME_THRESHOLD = 20.seconds + PARTNERS_RECEIVED_AMOUNT_DELTA = 0.000001 + def create_invoice(money) Invoice.create!(amount: money, order_public_id: order.public_id, address: order.income_account_emoney) end @@ -15,6 +19,8 @@ def update_invoice_state! transaction = transaction_for(invoice) return if transaction.nil? + invoice.has_transaction! if invoice.pending? + update_invoice_details(invoice: invoice, transaction: transaction) invoice.pay!(payload: transaction) if invoice.complete_payment? end @@ -31,7 +37,7 @@ def async_invoice_state_updater? def update_invoice_details(invoice:, transaction:) invoice.transaction_created_at ||= Time.parse(transaction[:datetime]) - invoice.transaction_id ||= transaction[:txid] + invoice.transaction_id ||= transaction[:txid] || transaction[:hash] invoice.confirmations = transaction[:confirmations] invoice.save! end @@ -40,20 +46,75 @@ def transaction_for(invoice) if invoice.transaction_id client.transaction_details(invoice.transaction_id)[:payload] else - currency = invoice.amount_currency.to_s - response = client.address_transactions(currency: currency, address: invoice.address) + response = client.address_transactions(invoice.address) + raise response[:meta][:error][:message] if response.dig(:meta, :error, :message) + response[:payload].find do |transaction| - received_amount = transaction[:received][invoice.address] - received_amount&.to_d == invoice.amount.to_d && Time.parse(transaction[:datetime]) > invoice.created_at - end + match_transaction?(transaction) + end if response[:payload] end end + def match_transaction?(transaction) + amount = parse_received_amount(transaction) + transaction_created_at = timestamp_in_utc(transaction[:timestamp]) + invoice_created_at = expected_invoice_created_at + return false if invoice_created_at >= transaction_created_at + + time_diff = (transaction_created_at - invoice_created_at) / 1.minute + match_by_amount_and_time?(amount, time_diff) || match_by_txid_amount_and_time?(amount, transaction[:txid], time_diff) + end + + def match_by_amount_and_time?(amount, time_diff) + match_amount?(amount) && match_transaction_time_threshold?(time_diff) + end + + def match_by_txid_amount_and_time?(amount, txid, time_diff) + invoice.possible_transaction_id.present? && + match_txid?(txid) && + match_amount_with_delta?(amount) && + match_transaction_time_threshold?(time_diff) + end + + def match_amount?(received_amount) + received_amount.to_d == invoice.amount.to_d + end + + def match_amount_with_delta?(received_amount) + amount_diff = received_amount.to_d - invoice.amount.to_d + amount_diff >= 0 && amount_diff <= PARTNERS_RECEIVED_AMOUNT_DELTA + end + + def match_transaction_time_threshold?(time_diff) + time_diff.round.minutes < TRANSACTION_TIME_THRESHOLD + end + + def match_txid?(txid) + txid == invoice.possible_transaction_id + end + + def parse_received_amount(transaction) + received_amount = transaction[:amount] + received_amount = transaction[:received][invoice.address] unless transaction[:received][invoice.address] == invoice.address + received_amount + end + + def timestamp_in_utc(timestamp) + DateTime.strptime(timestamp.to_s,'%s').utc + end + + def expected_invoice_created_at + invoice_created_at = invoice.created_at.utc + invoice_created_at -= ETC_TIME_THRESHOLD if invoice.amount_currency == 'ETC' + invoice_created_at + end + def client @client ||= begin wallet = order.income_wallet - api_key = wallet.api_key.presence || wallet.parent&.api_key - Client.new(api_key) + currency = wallet.currency.to_s.downcase + + Client.new(currency: currency).invoice.new(api_key: api_key, currency: currency) end end end diff --git a/lib/payment_services/crypto_apis/payout.rb b/lib/payment_services/crypto_apis/payout.rb new file mode 100644 index 00000000..09e9933d --- /dev/null +++ b/lib/payment_services/crypto_apis/payout.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class PaymentServices::CryptoApis + class Payout < PaymentServices::ApplicationRecord + CONFIRMATIONS_FOR_COMPLETE = 1 + include WorkflowActiverecord + self.table_name = 'crypto_apis_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :address, :fee, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + end + state :completed + state :failed + end + + def pay(txid:) + update(txid: txid) + end + + def success? + return false if confirmations.nil? + + confirmations >= CONFIRMATIONS_FOR_COMPLETE + end + + def order_payout + @order_payout ||= OrderPayout.find(order_payout_id) + end + end +end diff --git a/lib/payment_services/crypto_apis/payout_adapter.rb b/lib/payment_services/crypto_apis/payout_adapter.rb new file mode 100644 index 00000000..e348695c --- /dev/null +++ b/lib/payment_services/crypto_apis/payout_adapter.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::CryptoApis + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + delegate :outcome_transaction_fee_amount, to: :payment_system + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + raise 'amount is not a Money' unless amount.is_a? Money + + make_payout( + amount: amount, + address: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + @payout_id = payout_id + return if payout.pending? + + response = client.transaction_details(payout.txid) + + payout.update!( + confirmations: response[:payload][:confirmations], + fee: response[:payload][:fee].to_f + ) if response[:payload] + payout.confirm! if payout.success? + + response[:payload] + end + + def payout + @payout ||= Payout.find_by(id: payout_id) + end + + private + + attr_accessor :payout_id + + def make_payout(amount:, address:, order_payout_id:) + fee = outcome_transaction_fee_amount || provider_fee + raise "Fee is too low: #{fee}" if fee < 0.00000001 + + @payout_id = create_payout!(amount: amount, address: address, fee: fee, order_payout_id: order_payout_id).id + + response = client.make_payout(payout: payout, wallet_transfers: wallet_transfers) + raise "Can't process payout: #{response[:meta][:error][:message]}" if response.dig(:meta, :error, :message) + + # NOTE: hex for: ETH/ETC. txid for: BTC/OMNI/BCH/LTC/DOGE/DASH + hash = response[:payload][:txid] || response[:payload][:hex] + raise "Didn't get transaction hash" unless hash + + payout.pay!(txid: hash) + end + + def provider_fee + response = client.transactions_average_fee + raise "Can't get transaction fee: #{response[:meta][:error][:message]}" if response.dig(:meta, :error, :message) + + payload = response[:payload] + fee = payload[:standard].to_f + fee = payload[:average].to_f if fee == 0.0 + fee + end + + def client + @client ||= begin + currency = wallet.currency.to_s.downcase + + Client.new(currency: currency).payout.new(api_key: api_key, currency: currency) + end + end + + def create_payout!(amount:, address:, fee:, order_payout_id:) + Payout.create!(amount: amount, address: address, fee: fee, order_payout_id: order_payout_id) + end + end +end diff --git a/lib/payment_services/crypto_apis/payout_clients/base_client.rb b/lib/payment_services/crypto_apis/payout_clients/base_client.rb new file mode 100644 index 00000000..577441da --- /dev/null +++ b/lib/payment_services/crypto_apis/payout_clients/base_client.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative '../clients/base_client' + +class PaymentServices::CryptoApis + module PayoutClients + class BaseClient < PaymentServices::CryptoApis::Clients::BaseClient + DEFAULT_PARAMS = { replaceable: true } + + def make_payout(payout:, wallet_transfers:) + safely_parse http_request( + url: "#{base_url}/txs/new", + method: :POST, + body: api_query_for(payout, wallet_transfers) + ) + end + + def transactions_average_fee + safely_parse(http_request( + url: "#{base_url}/txs/fee", + method: :GET + )) + end + + private + + def api_query_for(payout, wallet_transfers) + { + createTx: { + inputs: inputs(wallet_transfers), + outputs: [{ address: payout.address, value: payout.amount.to_d }], + fee: { value: payout.fee }.merge(pay_fee_from_address(payout)) + }, + wifs: wifs(wallet_transfers) + }.merge(DEFAULT_PARAMS) + end + + def inputs(wallet_transfers) + wallet_transfers.map { |wallet_transfer| { 'address' => wallet_transfer.wallet.account, 'value' => wallet_transfer.amount.to_f } } + end + + def wifs(wallet_transfers) + wallet_transfers.map { |wallet_transfer| wallet_transfer.wallet.outcome_api_secret } + end + + def pay_fee_from_address(payout) + wallet_for_fee = payout.order_payout.wallet_for_fee + return {} if wallet_for_fee.nil? + + { address: wallet_for_fee.account } + end + end + end +end diff --git a/lib/payment_services/crypto_apis/payout_clients/dash_client.rb b/lib/payment_services/crypto_apis/payout_clients/dash_client.rb new file mode 100644 index 00000000..e29bce6f --- /dev/null +++ b/lib/payment_services/crypto_apis/payout_clients/dash_client.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require_relative 'base_client' + +class PaymentServices::CryptoApis + module PayoutClients + class DashClient < PaymentServices::CryptoApis::PayoutClients::BaseClient + DEPRECATED_OPTION = { deprecated_rpc: 'sign_raw_transaction' } + + def make_payout(payout:, wallet_transfers:) + safely_parse http_request( + url: "#{base_url}/txs/new", + method: :POST, + body: api_query_for(payout, wallet_transfers).merge(DEPRECATED_OPTION) + ) + end + + private + + def base_url + "#{API_URL}/bc/dash/#{NETWORK}" + end + end + end +end diff --git a/lib/payment_services/crypto_apis/payout_clients/ethereum_client.rb b/lib/payment_services/crypto_apis/payout_clients/ethereum_client.rb new file mode 100644 index 00000000..fbd06260 --- /dev/null +++ b/lib/payment_services/crypto_apis/payout_clients/ethereum_client.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative '../clients/ethereum_client' + +class PaymentServices::CryptoApis + module PayoutClients + class EthereumClient < PaymentServices::CryptoApis::Clients::EthereumClient + GAS_LIMIT = 100_000 + + def make_payout(payout:, wallet_transfers:) + safely_parse http_request( + url: "#{base_url}/txs/new-pvtkey", + method: :POST, + body: api_query_for(payout, wallet_transfers.first.wallet) + ) + end + + def transactions_average_fee + safely_parse(http_request( + url: "#{base_url}/txs/fee", + method: :GET + )) + end + + private + + def api_query_for(payout, wallet) + { + fromAddress: wallet.account, + toAddress: payout.address, + value: payout.amount.to_d, + gasPrice: payout.fee.to_i, + gasLimit: GAS_LIMIT, + privateKey: wallet.outcome_api_secret + } + end + end + end +end diff --git a/lib/payment_services/crypto_apis/payout_clients/omni_client.rb b/lib/payment_services/crypto_apis/payout_clients/omni_client.rb new file mode 100644 index 00000000..3d0719c5 --- /dev/null +++ b/lib/payment_services/crypto_apis/payout_clients/omni_client.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require_relative '../clients/omni_client' + +class PaymentServices::CryptoApis + module PayoutClients + class OmniClient < PaymentServices::CryptoApis::Clients::OmniClient + TOKEN_PROPERTY_ID = 2 + + def make_payout(payout:, wallet:) + safely_parse http_request( + url: "#{base_url}/txs/new", + method: :POST, + body: api_query_for(payout, wallet) + ) + end + + def transactions_average_fee + safely_parse(http_request( + url: "#{base_url}/txs/fee", + method: :GET + )) + end + + private + + def api_query_for(payout, wallet) + { + from: wallet.account, + to: payout.address, + value: payout.amount.to_d, + fee: payout.fee, + propertyID: TOKEN_PROPERTY_ID, + wif: wallet.outcome_api_secret + } + end + end + end +end diff --git a/lib/payment_services/crypto_apis_v2.rb b/lib/payment_services/crypto_apis_v2.rb new file mode 100644 index 00000000..a355fb13 --- /dev/null +++ b/lib/payment_services/crypto_apis_v2.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module PaymentServices + class CryptoApisV2 < Base + autoload :Invoicer, 'payment_services/crypto_apis_v2/invoicer' + autoload :PayoutAdapter, 'payment_services/crypto_apis_v2/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + + def self.payout_contains_fee? + true + end + end +end diff --git a/lib/payment_services/crypto_apis_v2/blockchain.rb b/lib/payment_services/crypto_apis_v2/blockchain.rb new file mode 100644 index 00000000..42d78172 --- /dev/null +++ b/lib/payment_services/crypto_apis_v2/blockchain.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class PaymentServices::CryptoApisV2 + class Blockchain + API_URL = 'https://rest.cryptoapis.io/v2' + NETWORK = 'mainnet' + CURRENCY_TO_BLOCKCHAIN = { + 'btc' => 'bitcoin', + 'bch' => 'bitcoin-cash', + 'ltc' => 'litecoin', + 'doge' => 'dogecoin', + 'dsh' => 'dash', + 'eth' => 'ethereum', + 'etc' => 'ethereum-classic', + 'bnb' => 'binance-smart-chain', + 'zec' => 'zcash', + 'xrp' => 'xrp' + }.freeze + TOKEN_NETWORK_TO_BLOCKCHAIN = { + 'trc20' => 'tron', + 'bep20' => 'binance-smart-chain', + 'erc20' => 'ethereum' + }.freeze + + ACCOUNT_MODEL_BLOCKCHAINS = %w(ethereum ethereum-classic binance-smart-chain xrp) + FUNGIBLE_TOKENS = %w(usdt) + delegate :xrp?, :bitcoin?, to: :blockchain + + def initialize(currency:, token_network:) + @currency = currency + @token_network = token_network + end + + def address_transactions_endpoint(merchant_id:, address:) + if blockchain.xrp? + "#{blockchain_data_prefix}/xrp-specific/#{NETWORK}/addresses/#{address}/transactions" + elsif fungible_token? || currency.inquiry.bnb? + "#{proccess_payout_base_url(merchant_id)}/transactions" + else + "#{blockchain_data_prefix}/#{blockchain}/#{NETWORK}/addresses/#{address}/transactions" + end + end + + def transaction_details_endpoint(transaction_id) + if blockchain.xrp? + "#{blockchain_data_prefix}/xrp-specific/#{NETWORK}/transactions/#{transaction_id}" + else + "#{API_URL}/wallet-as-a-service/wallets/#{blockchain}/#{NETWORK}/transactions/#{transaction_id}" + end + end + + def request_details_endpoint(request_id) + "#{API_URL}/wallet-as-a-service/transactionRequests/#{request_id}" + end + + def process_payout_endpoint(wallet:) + if blockchain.tron? + "#{proccess_payout_base_url(wallet.merchant_id)}/addresses/#{wallet.account}/feeless-token-transaction-requests" + elsif account_model_blockchain? && currency.inquiry.usdt? + "#{proccess_payout_base_url(wallet.merchant_id)}/addresses/#{wallet.account}/token-transaction-requests" + elsif account_model_blockchain? + "#{proccess_payout_base_url(wallet.merchant_id)}/addresses/#{wallet.account}/transaction-requests" + else + "#{proccess_payout_base_url(wallet.merchant_id)}/transaction-requests" + end + end + + def fungible_token? + FUNGIBLE_TOKENS.include?(currency) + end + + def account_model_blockchain? + ACCOUNT_MODEL_BLOCKCHAINS.include?(blockchain) + end + + private + + attr_reader :currency, :token_network + + def blockchain + @blockchain ||= build_blockchain.inquiry + end + + def build_blockchain + currency.inquiry.usdt? ? TOKEN_NETWORK_TO_BLOCKCHAIN[token_network] : CURRENCY_TO_BLOCKCHAIN[currency] + end + + def proccess_payout_base_url(merchant_id) + "#{API_URL}/wallet-as-a-service/wallets/#{merchant_id}/#{blockchain}/#{NETWORK}" + end + + def blockchain_data_prefix + "#{API_URL}/blockchain-data" + end + end +end diff --git a/lib/payment_services/crypto_apis_v2/client.rb b/lib/payment_services/crypto_apis_v2/client.rb new file mode 100644 index 00000000..3f6344de --- /dev/null +++ b/lib/payment_services/crypto_apis_v2/client.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require_relative 'blockchain' + +class PaymentServices::CryptoApisV2 + class Client < ::PaymentServices::Base::Client + include AutoLogger + + DEFAULT_FEE_PRIORITY = 'standard' + LOW_FEE_PRIORITY = 'slow' + USDT_TRC_FEE_LIMIT = '1000000000' + + def initialize(api_key:, api_secret:, currency:, token_network:) + @api_key = api_key + @api_secret = api_secret + @blockchain = Blockchain.new(currency: currency, token_network: token_network) + end + + def address_transactions(invoice) + safely_parse http_request( + url: blockchain.address_transactions_endpoint(merchant_id: invoice.merchant_id, address: invoice.address), + method: :GET, + headers: build_headers + ) + end + + def transaction_details(transaction_id) + safely_parse http_request( + url: blockchain.transaction_details_endpoint(transaction_id), + method: :GET, + headers: build_headers + ) + end + + def request_details(request_id) + safely_parse http_request( + url: blockchain.request_details_endpoint(request_id), + method: :GET, + headers: build_headers + ) + end + + def make_payout(payout:, wallet_transfers:) + wallet_transfer = wallet_transfers.first + + safely_parse http_request( + url: blockchain.process_payout_endpoint(wallet: wallet_transfer.wallet), + method: :POST, + body: build_payout_request_body(payout: payout, wallet_transfer: wallet_transfer).to_json, + headers: build_headers + ) + end + + def classic_to_x_address(classic_address, address_tag) + return classic_address unless address_tag.present? + + safely_parse(http_request( + url: "https://rest.cryptoapis.io/v2/blockchain-tools/xrp/mainnet/encode-x-address/#{classic_address}/#{address_tag}", + method: :GET, + headers: build_headers + ))['data']['item']['xAddress'] + end + + private + + attr_reader :api_key, :api_secret, :blockchain + + def build_headers + { + 'Content-Type' => 'application/json', + 'Cache-Control' => 'no-cache', + 'X-API-Key' => api_key + } + end + + def build_payout_request_body(payout:, wallet_transfer:) + transaction_body = + if blockchain.fungible_token? + build_fungible_payout_body(payout, wallet_transfer, blockchain) + elsif blockchain.account_model_blockchain? + build_account_payout_body(payout, wallet_transfer) + else + build_utxo_payout_body(payout, wallet_transfer) + end + + { data: { item: transaction_body } } + end + + def build_account_payout_body(payout, wallet_transfer) + body = { + amount: wallet_transfer.amount.to_f.to_s, + feePriority: account_fee_priority, + callbackSecretKey: api_secret, + recipientAddress: payout.address + } + body[:recipientAddress] = classic_to_x_address(body[:recipientAddress], payout.order_fio) if blockchain.xrp? + body + end + + def build_utxo_payout_body(payout, wallet_transfer) + { + callbackSecretKey: api_secret, + feePriority: utxo_fee_priority, + recipients: [{ + address: payout.address, + amount: wallet_transfer.amount.to_f.to_s + }] + } + end + + def build_fungible_payout_body(payout, wallet_transfer, blockchain) + token_address = wallet_transfer.wallet.payment_system.token_address.downcase + + body = build_account_payout_body(payout, wallet_transfer) + .merge(tokenIdentifier: token_address) + blockchain.account_model_blockchain? ? body[:feePriority] = DEFAULT_FEE_PRIORITY : body[:feeLimit] = USDT_TRC_FEE_LIMIT + body + end + + def account_fee_priority + blockchain.fungible_token? || blockchain.xrp? ? LOW_FEE_PRIORITY : DEFAULT_FEE_PRIORITY + end + + def utxo_fee_priority + blockchain.bitcoin? ? LOW_FEE_PRIORITY : DEFAULT_FEE_PRIORITY + end + end +end diff --git a/lib/payment_services/crypto_apis_v2/invoice.rb b/lib/payment_services/crypto_apis_v2/invoice.rb new file mode 100644 index 00000000..175e1753 --- /dev/null +++ b/lib/payment_services/crypto_apis_v2/invoice.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class PaymentServices::CryptoApisV2 + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord + self.table_name = 'crypto_apis_invoices' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :order_public_id, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :has_transaction, transitions_to: :with_transaction + end + state :with_transaction do + on_entry do + order.make_reserve! + end + event :pay, transitions_to: :paid + event :cancel, transitions_to: :cancelled + end + + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount, hash: transaction_id) + end + end + state :cancelled + end + + def pay(payload:) + update(payload: payload) + end + + def order + Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + end + + def update_invoice_details!(transaction:) + has_transaction! if pending? + update!( + transaction_created_at: transaction.created_at, + transaction_id: transaction.id, + confirmed: transaction.confirmed? + ) + pay!(payload: transaction) if confirmed? + end + + def merchant_id + @merchant_id ||= order.income_wallet.merchant_id + end + end +end diff --git a/lib/payment_services/crypto_apis_v2/invoicer.rb b/lib/payment_services/crypto_apis_v2/invoicer.rb new file mode 100644 index 00000000..a19e5344 --- /dev/null +++ b/lib/payment_services/crypto_apis_v2/invoicer.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' +require_relative 'transaction_repository' + +class PaymentServices::CryptoApisV2 + class Invoicer < ::PaymentServices::Base::Invoicer + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id, address: order.income_account_emoney) + end + + def update_invoice_state! + transaction = transaction_for(invoice) + return if transaction.nil? + + invoice.update_invoice_details!(transaction: transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + def async_invoice_state_updater? + true + end + + private + + def transaction_for(invoice) + TransactionRepository.new(collect_transactions).find_for(invoice) + end + + def collect_transactions + response = client.address_transactions(invoice) + raise response['error']['message'] if response['error'] + + response['data']['items'] + end + + def wallet + @wallet ||= order.income_wallet + end + + def client + @client ||= Client.new(api_key: api_key, api_secret: api_secret, currency: wallet.currency.to_s.downcase, token_network: wallet.payment_system.token_network) + end + end +end diff --git a/lib/payment_services/crypto_apis_v2/payout.rb b/lib/payment_services/crypto_apis_v2/payout.rb new file mode 100644 index 00000000..348930b3 --- /dev/null +++ b/lib/payment_services/crypto_apis_v2/payout.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class PaymentServices::CryptoApisV2 + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + self.table_name = 'crypto_apis_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :address, :fee, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(request_id:) + update(request_id: request_id) + end + + def order_payout + @order_payout ||= OrderPayout.find(order_payout_id) + end + + def order_fio + order_payout.order.outcome_fio.presence || order_payout.order.outcome_unk + end + + def update_payout_details!(transaction:) + update!(confirmed: transaction.confirmed?, fee: transaction.fee) + + confirm! if confirmed? + end + end +end diff --git a/lib/payment_services/crypto_apis_v2/payout_adapter.rb b/lib/payment_services/crypto_apis_v2/payout_adapter.rb new file mode 100644 index 00000000..1d4deab4 --- /dev/null +++ b/lib/payment_services/crypto_apis_v2/payout_adapter.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' +require_relative 'transaction' + +class PaymentServices::CryptoApisV2 + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + FAILED_PAYOUT_STATUSES = %w(failed rejected) + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + raise 'amount is not a Money' unless amount.is_a? Money + + make_payout( + amount: amount, + address: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + unless payout.txid + response = client.request_details(payout.request_id) + raise response['error']['message'] if response['error'] + + transaction = response['data']['item'] + payout.fail! if FAILED_PAYOUT_STATUSES.include?(transaction['transactionRequestStatus']) + payout.update!(txid: transaction['transactionId']) if transaction['transactionId'] + else + response = client.transaction_details(payout.txid) + raise response['error']['message'] if response['error'] + + payout.update_payout_details!(transaction: build_transaction(source: response['data']['item'])) + end + + response + end + + private + + def make_payout(amount:, address:, order_payout_id:) + payout = create_payout!(amount: amount, address: address, fee: 0, order_payout_id: order_payout_id) + + response = client.make_payout(payout: payout, wallet_transfers: wallet_transfers) + raise response['error']['message'] if response['error'] + + request_id = response['data']['item']['transactionRequestId'] + payout.pay!(request_id: request_id) + end + + def client + @client ||= Client.new(api_key: api_key, api_secret: api_secret, currency: wallet.currency.to_s.downcase, token_network: wallet.payment_system.token_network) + end + + def create_payout!(amount:, address:, fee:, order_payout_id:) + Payout.create!(amount: amount, address: address, fee: fee, order_payout_id: order_payout_id) + end + + def build_transaction(source:) + Transaction.build_from(transaction_hash: source['transactionHash'], created_at: source['transactionTimestamp'], currency: wallet.currency.to_s.downcase, source: source) + end + end +end diff --git a/lib/payment_services/crypto_apis_v2/transaction.rb b/lib/payment_services/crypto_apis_v2/transaction.rb new file mode 100644 index 00000000..14f19567 --- /dev/null +++ b/lib/payment_services/crypto_apis_v2/transaction.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +class PaymentServices::CryptoApisV2 + class Transaction + SUCCESS_XRP_STATUS = 'tesSUCCESS' + + include Virtus.model + + attribute :id, String + attribute :created_at, DateTime + attribute :currency, String + attribute :source, Hash + + def self.build_from(transaction_hash:, created_at:, currency:, source:) + new( + id: transaction_hash, + created_at: created_at, + currency: currency, + source: source + ) + end + + def to_s + source.to_s + end + + def confirmed? + send("#{currency}_transaction_confirmed?") + end + + def fee + source.dig('fee', 'amount') || 0 + end + + private + + def method_missing(method_name) + super unless method_name.end_with?('_transaction_confirmed?') + + generic_transaction_confirmed? + end + + def generic_transaction_confirmed? + source['minedInBlockHeight'] > 0 + end + + def btc_transaction_confirmed? + source['isConfirmed'] + end + + def xrp_transaction_confirmed? + status == SUCCESS_XRP_STATUS + end + + def bnb_transaction_confirmed? + source['isConfirmed'] + end + + def usdt_transaction_confirmed? + source['isConfirmed'] + end + + def status + source['status'].inquiry + end + end +end diff --git a/lib/payment_services/crypto_apis_v2/transaction_repository.rb b/lib/payment_services/crypto_apis_v2/transaction_repository.rb new file mode 100644 index 00000000..84dda814 --- /dev/null +++ b/lib/payment_services/crypto_apis_v2/transaction_repository.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require_relative 'transaction' +require_relative 'blockchain' + +class PaymentServices::CryptoApisV2 + class TransactionRepository + TRANSACTION_TIME_THRESHOLD = 30.minutes + BASIC_TIME_COUNTDOWN = 1.minute + + def initialize(transactions) + @transactions = transactions + end + + def find_for(invoice) + @invoice = invoice + send("find_#{invoice.amount_currency.downcase}_transaction") + end + + private + + attr_reader :invoice, :transactions + + def currency + @currency ||= wallet.currency.to_s.downcase + end + + def wallet + @wallet ||= invoice.order.income_wallet + end + + def build_transaction(id:, created_at:, currency:, source:) + Transaction.build_from(transaction_hash: id, created_at: created_at, currency: currency, source: source) + end + + def method_missing(method_name) + super unless method_name.start_with?('find_') && method_name.end_with?('_transaction') + + raw_transaction = transactions.find { |transaction| find_generic_transaction?(transaction) } + return unless raw_transaction + + build_transaction( + id: raw_transaction['transactionHash'], + created_at: timestamp_in_utc(raw_transaction['timestamp']), + currency: currency, + source: raw_transaction + ) + end + + def find_generic_transaction?(transaction) + amount = parse_received_amount(transaction) + transaction_created_at = timestamp_in_utc(transaction['timestamp']) + invoice_created_at = invoice.created_at.utc + + time_diff = (transaction_created_at - invoice_created_at) / BASIC_TIME_COUNTDOWN + invoice_created_at < transaction_created_at && match_by_amount_and_time?(amount, time_diff) + end + + def find_usdt_transaction + raw_transaction = transactions.find { |transaction| find_token?(transaction) } + return unless raw_transaction + + build_transaction( + id: raw_transaction['transactionId'], + created_at: timestamp_in_utc(raw_transaction['timestamp']), + currency: currency, + source: raw_transaction + ) + end + + def find_bnb_transaction + raw_transaction = transactions.find { |transaction| find_bnb_token?(transaction) } + return unless raw_transaction + + build_transaction( + id: raw_transaction['transactionId'], + created_at: timestamp_in_utc(raw_transaction['timestamp']), + currency: currency, + source: raw_transaction + ) + end + + def match_by_amount_and_time?(amount, time_diff) + match_amount?(amount) && match_transaction_time_threshold?(time_diff) + end + + def match_amount?(received_amount) + received_amount.to_d == invoice.amount.to_d + end + + def match_transaction_time_threshold?(time_diff) + time_diff.round.minutes < TRANSACTION_TIME_THRESHOLD + end + + def find_token?(transaction) + amount = parse_tokens_amount(transaction) + transaction_created_at = timestamp_in_utc(transaction['timestamp']) + invoice_created_at = invoice.created_at.utc + + time_diff = (transaction_created_at - invoice_created_at) / BASIC_TIME_COUNTDOWN + invoice_created_at < transaction_created_at && match_by_amount_and_time?(amount, time_diff) && match_by_contract_address?(transaction) + end + + def find_bnb_token?(transaction) + amount = parse_bnb_tokens_amount(transaction) + transaction_created_at = timestamp_in_utc(transaction['timestamp']) + invoice_created_at = invoice.created_at.utc + + time_diff = (transaction_created_at - invoice_created_at) / BASIC_TIME_COUNTDOWN + invoice_created_at < transaction_created_at && match_by_amount_and_time?(amount, time_diff) + end + + def match_by_contract_address?(transaction) + transaction['fungibleTokens'].first['type'].tr('-','') == wallet.payment_system.token_network.upcase + end + + def parse_received_amount(transaction) + recipient = transaction['recipients'].find { |recipient| recipient['address'].include?(invoice.address) } + recipient ? recipient['amount'] : 0 + end + + def parse_tokens_amount(transaction) + tokens = transaction['fungibleTokens'].first + return 0 unless tokens.is_a? Hash + tokens['recipient'] == invoice.address ? tokens['amount'] : 0 + end + + def parse_bnb_tokens_amount(transaction) + recipient = transaction['recipients'].find { |recipient| recipient['address'] == invoice.address } + recipient ? recipient['amount'] : 0 + end + + def timestamp_in_utc(timestamp) + DateTime.strptime(timestamp.to_s,'%s').utc + end + end +end diff --git a/lib/payment_services/cryptomus.rb b/lib/payment_services/cryptomus.rb new file mode 100644 index 00000000..de521118 --- /dev/null +++ b/lib/payment_services/cryptomus.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class Cryptomus < Base + autoload :Invoicer, 'payment_services/cryptomus/invoicer' + autoload :PayoutAdapter, 'payment_services/cryptomus/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/cryptomus/client.rb b/lib/payment_services/cryptomus/client.rb new file mode 100644 index 00000000..adf75fdb --- /dev/null +++ b/lib/payment_services/cryptomus/client.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +class PaymentServices::Cryptomus + class Client < ::PaymentServices::Base::Client + API_URL = 'https://api.heleket.com/v1' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/payment", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def create_payout(params:) + safely_parse http_request( + url: "#{API_URL}/payout", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def invoice(params:) + safely_parse http_request( + url: "#{API_URL}/payment/info", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def payout(params:) + safely_parse http_request( + url: "#{API_URL}/payout/info", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def fee + safely_parse http_request( + url: "#{API_URL}/payout/services", + method: :POST, + body: {}.to_json, + headers: build_headers(signature: build_signature) + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_signature(params = {}) + Digest::MD5.hexdigest(Base64.encode64(params.to_json).gsub(/\n/, '') + secret_key) + end + + def build_headers(signature:) + { + 'merchant' => api_key, + 'sign' => signature, + 'Content-Type' => 'application/json' + } + end + end +end diff --git a/lib/payment_services/cryptomus/invoice.rb b/lib/payment_services/cryptomus/invoice.rb new file mode 100644 index 00000000..9b3eb418 --- /dev/null +++ b/lib/payment_services/cryptomus/invoice.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class PaymentServices::Cryptomus + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATES = %w(paid paid_over wrong_amount_waiting wrong_amount) + FAILED_PROVIDER_STATES = %w(fail cancel system_fail) + + self.table_name = 'cryptomus_invoices' + + monetize :amount_cents, as: :amount + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + event :cancel, transitions_to: :cancelled + end + + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount, hash: transaction_id) + end + end + state :cancelled + end + + def transaction_created_at + nil + end + + private + + def provider_succeed? + provider_state.in? SUCCESS_PROVIDER_STATES + end + + def provider_failed? + provider_state.in? FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/cryptomus/invoicer.rb b/lib/payment_services/cryptomus/invoicer.rb new file mode 100644 index 00000000..0d941857 --- /dev/null +++ b/lib/payment_services/cryptomus/invoicer.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Cryptomus + class Invoicer < ::PaymentServices::Base::Invoicer + Error = Class.new StandardError + USDT_NETWORK_TO_CURRENCY = { + 'trc20' => 'TRON', + 'erc20' => 'ETH', + 'ton' => 'TON', + 'sol' => 'SOL', + 'POLYGON' => 'POLYGON', + 'bep20' => 'BSC' + }.freeze + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_invoice(params: invoice_params) + raise Error, "Can't create invoice: #{response['message']}" if response['message'] + + invoice.update!(deposit_id: response.dig('result', 'uuid')) + PaymentServices::Base::Wallet.new( + address: response.dig('result', 'address'), + name: nil + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.invoice(params: { uuid: invoice.deposit_id }) + return if transaction.dig('result', 'payment_amount').nil? + + status = transaction.dig('result', 'payment_status') + if status.in?(Invoice::SUCCESS_PROVIDER_STATES) + recalculate_order(transaction) if recalculate_on_different_amount + end + invoice.update_state_by_provider(status) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :token_network, :recalculate_on_different_amount, to: :income_payment_system + delegate :income_payment_system, to: :order + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_params + currency = invoice.amount_currency.to_s.downcase.inquiry + currency = 'dash'.inquiry if currency.dsh? + params = { + amount: invoice.amount.to_f.to_s, + currency: currency.upcase, + order_id: order.public_id.to_s, + lifetime: order.income_payment_timeout.to_i + } + params[:network] = currency.usdt? || currency.bnb? ? network(currency) : currency.upcase + params + end + + def network(currency) + return 'BSC' if currency.bnb? + + USDT_NETWORK_TO_CURRENCY[token_network] || 'USDT' + end + + def recalculate_order(transaction) + order.operator_recalculate!(transaction.dig('result', 'payment_amount')) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/cryptomus/payout.rb b/lib/payment_services/cryptomus/payout.rb new file mode 100644 index 00000000..ddbe4816 --- /dev/null +++ b/lib/payment_services/cryptomus/payout.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PaymentServices::Cryptomus + class Payout < ::PaymentServices::Base::FiatPayout + self.table_name = 'cryptomus_payouts' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state.in? Invoice::SUCCESS_PROVIDER_STATES + end + + def provider_failed? + provider_state.in? Invoice::FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/cryptomus/payout_adapter.rb b/lib/payment_services/cryptomus/payout_adapter.rb new file mode 100644 index 00000000..411090f3 --- /dev/null +++ b/lib/payment_services/cryptomus/payout_adapter.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::Cryptomus + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + Error = Class.new StandardError + USDT_NETWORK_TO_CURRENCY = { + 'trc20' => 'TRON', + 'erc20' => 'ETH', + 'ton' => 'TON', + 'sol' => 'SOL', + 'POLYGON' => 'POLYGON', + 'bep20' => 'BSC' + }.freeze + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + transaction = client.payout(params: { uuid: payout.withdrawal_id } ) + payout.update(txid: transaction.dig('result', 'txid')) + payout.update_state_by_provider(transaction.dig('result', 'status')) + transaction + end + + private + + attr_reader :payout + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + response = client.create_payout(params: payout_params) + raise Error, "Can't create payout: #{response['message']}" if response['message'] + + payout.pay!(withdrawal_id: response.dig('result', 'uuid')) + end + + def payout_params + currency = payout.amount_currency.to_s.downcase.inquiry + currency = 'dash'.inquiry if currency.dsh? + network = currency.usdt? || currency.bnb? ? network(currency) : currency.upcase + params = { + amount: (payout.amount.to_f + fee_by(currency: currency.upcase, network: network)).to_d.to_s, + currency: currency.upcase, + network: network, + order_id: order.public_id.to_s, + address: payout.destination_account, + is_subtract: true + } + params[:memo] = order.outcome_fio if currency.ton? + params + end + + def network(currency) + return 'BSC' if currency.bnb? + + USDT_NETWORK_TO_CURRENCY[order.outcome_payment_system.token_network] || 'USDT' + end + + def order + @order ||= OrderPayout.find(payout.order_payout_id).order + end + + def fee + @fee ||= client.fee['result'] + end + + def fee_by(currency:, network:) + 0 + # fee.find { |e| e['network'] == network && e['currency'] == currency }&.dig('commission', 'fee_amount').to_f + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/erapay.rb b/lib/payment_services/erapay.rb new file mode 100644 index 00000000..6f595645 --- /dev/null +++ b/lib/payment_services/erapay.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class Erapay < Base + autoload :Invoicer, 'payment_services/erapay/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/erapay/client.rb b/lib/payment_services/erapay/client.rb new file mode 100644 index 00000000..309d94fd --- /dev/null +++ b/lib/payment_services/erapay/client.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class PaymentServices::Erapay + class Client < ::PaymentServices::Base::Client + API_URL = 'https://erapay.ru/api' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/createOrder", + method: :POST, + body: URI.encode_www_form(params.merge(token: api_key, shop_id: secret_key)), + headers: build_headers + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers + { + 'Content-Type' => 'application/x-www-form-urlencoded' + } + end + end +end diff --git a/lib/payment_services/erapay/invoice.rb b/lib/payment_services/erapay/invoice.rb new file mode 100644 index 00000000..89e78e61 --- /dev/null +++ b/lib/payment_services/erapay/invoice.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class PaymentServices::Erapay + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = '0' + + self.table_name = 'erapay_invoices' + + monetize :amount_cents, as: :amount + + def can_be_confirmed?(income_money:, status:) + pending? && status == SUCCESS_PROVIDER_STATE && income_money == amount + end + + private + + def provider_succeed? + false + end + + def provider_failed? + false + end + end +end diff --git a/lib/payment_services/erapay/invoicer.rb b/lib/payment_services/erapay/invoicer.rb new file mode 100644 index 00000000..729e7882 --- /dev/null +++ b/lib/payment_services/erapay/invoicer.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Erapay + class Invoicer < ::PaymentServices::Base::Invoicer + Error = Class.new StandardError + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_invoice(params: invoice_params) + + raise Error, "Can't create invoice: #{response['data']['message']}" if response['data']['message'].present? + + invoice.update!( + deposit_id: response.dig('data', 'info', 'order_id'), + pay_url: response['data']['link'] + ) + end + + def pay_invoice_url + invoice.present? ? URI.parse(invoice.reload.pay_url) : '' + end + + def async_invoice_state_updater? + false + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :sbp?, to: :bank_resolver + + def bank_resolver + @bank_resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def invoice_params + { + unique_id: order.public_id.to_s, + amount: invoice.amount.to_i, + description: "Order ##{order.public_id.to_s}", + user_ip: order.remote_ip, + system_name: sbp? ? 'sbp' : 'card' + } + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/ex_pay.rb b/lib/payment_services/ex_pay.rb new file mode 100644 index 00000000..fb763c23 --- /dev/null +++ b/lib/payment_services/ex_pay.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class ExPay < Base + autoload :Invoicer, 'payment_services/ex_pay/invoicer' + autoload :PayoutAdapter, 'payment_services/ex_pay/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/ex_pay/client.rb b/lib/payment_services/ex_pay/client.rb new file mode 100644 index 00000000..43b0cae8 --- /dev/null +++ b/lib/payment_services/ex_pay/client.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class PaymentServices::ExPay + class Client < ::PaymentServices::Base::Client + API_URL = 'https://apiv2.expay.cash/api/transaction' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/create/in", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def create_payout(params:) + safely_parse http_request( + url: "#{API_URL}/create/out", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def transaction(tracker_id:) + params = { tracker_id: tracker_id } + safely_parse(http_request( + url: "#{API_URL}/get", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ))['transaction'] + end + + private + + attr_reader :api_key, :secret_key + + def build_headers(signature:) + { + 'Content-Type' => 'application/json', + 'ApiPublic' => api_key, + 'TimeStamp' => timestamp_string, + 'Signature' => signature + } + end + + def build_signature(params) + OpenSSL::HMAC.hexdigest('SHA512', secret_key, timestamp_string + params.to_json) + end + + def timestamp_string + @timestamp_string ||= Time.now.to_i.to_s + end + end +end diff --git a/lib/payment_services/ex_pay/invoice.rb b/lib/payment_services/ex_pay/invoice.rb new file mode 100644 index 00000000..ae6809ae --- /dev/null +++ b/lib/payment_services/ex_pay/invoice.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class PaymentServices::ExPay + class Invoice < ::PaymentServices::Base::FiatInvoice + INITIAL_PROVIDER_STATE = 'ACCEPTED' + SUCCESS_PROVIDER_STATE = 'SUCCESS' + FAILED_PROVIDER_STATE = 'ERROR' + + self.table_name = 'ex_pay_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/ex_pay/invoicer.rb b/lib/payment_services/ex_pay/invoicer.rb new file mode 100644 index 00000000..debe0c41 --- /dev/null +++ b/lib/payment_services/ex_pay/invoicer.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::ExPay + class Invoicer < ::PaymentServices::Base::Invoicer + MERCHANT_ID = '1' + CURRENCY_TO_PROVIDER_TOKEN = { + 'RUB' => 'CARDRUBP2P', + 'UZS' => 'UZSP2P', + 'AZN' => 'AZNP2P' + }.freeze + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_invoice(params: invoice_p2p_params) + raise response['description'] unless response['status'] == Invoice::INITIAL_PROVIDER_STATE + + invoice.update!( + deposit_id: response['tracker_id'], + pay_url: response['alter_refer'] + ) + end + + def pay_invoice_url + invoice.reload.pay_url if invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.transaction(tracker_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction['status']) if transaction + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :income_payment_system, :income_currency, to: :order + delegate :callback_url, to: :income_payment_system + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_p2p_params + { + refer_type: 'p2p_payform', + token: CURRENCY_TO_PROVIDER_TOKEN[income_currency.to_s], + sub_token: provider_bank, + amount: order.income_money.to_f, + client_transaction_id: order.public_id.to_s, + client_merchant_id: MERCHANT_ID, + fingerprint: "#{Rails.env}_user_id_#{order.user_id}", + transaction_description: order.public_id.to_s + } + end + + def provider_bank + @provider_bank ||= PaymentServices::Base::P2pBankResolver.new(adapter: self).card_bank + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/ex_pay/payout.rb b/lib/payment_services/ex_pay/payout.rb new file mode 100644 index 00000000..7a36e41c --- /dev/null +++ b/lib/payment_services/ex_pay/payout.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PaymentServices::ExPay + class Payout < ::PaymentServices::Base::FiatPayout + self.table_name = 'ex_pay_payouts' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == Invoice::SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == Invoice::FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/ex_pay/payout_adapter.rb b/lib/payment_services/ex_pay/payout_adapter.rb new file mode 100644 index 00000000..298cd0e3 --- /dev/null +++ b/lib/payment_services/ex_pay/payout_adapter.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::ExPay + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + PAYOUT_PROVIDER_TOKEN = 'CARDRUBP2P' + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + transaction = client.transaction(tracker_id: payout.withdrawal_id) + payout.update_state_by_provider(transaction['status']) if transaction + transaction + end + + private + + attr_reader :payout + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + response = client.create_payout(params: payout_params) + raise "Can't create payout: #{response['description']}" unless response['status'] == Invoice::INITIAL_PROVIDER_STATE + + payout.pay!(withdrawal_id: response['tracker_id']) + end + + def payout_params + order = OrderPayout.find(payout.order_payout_id).order + { + amount: payout.amount.to_i, + call_back_url: order.outcome_payment_system.callback_url, + client_transaction_id: "#{order.public_id}-#{payout.order_payout_id}", + receiver: payout.destination_account, + token: PAYOUT_PROVIDER_TOKEN + } + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/exmo.rb b/lib/payment_services/exmo.rb new file mode 100644 index 00000000..6b60f2e4 --- /dev/null +++ b/lib/payment_services/exmo.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class Exmo < Base + autoload :Invoicer, 'payment_services/exmo/invoicer' + autoload :PayoutAdapter, 'payment_services/exmo/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/exmo/client.rb b/lib/payment_services/exmo/client.rb new file mode 100644 index 00000000..28a37884 --- /dev/null +++ b/lib/payment_services/exmo/client.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class PaymentServices::Exmo + class Client < ::PaymentServices::Base::Client + API_URL = 'https://api.exmo.me/v1.1' + + def initialize(public_key:, secret_key:) + @public_key = public_key + @secret_key = secret_key + end + + def create_payout(params:) + body = URI.encode_www_form(params.merge(nonce: nonce)) + safely_parse http_request( + url: "#{API_URL}/withdraw_crypt", + method: :POST, + body: body, + headers: build_headers(build_signature(body)) + ) + end + + def wallet_operations(currency:, type:) + body = URI.encode_www_form({ + currency: currency, + type: type, + nonce: nonce + }) + safely_parse http_request( + url: "#{API_URL}/wallet_operations", + method: :POST, + body: body, + headers: build_headers(build_signature(body)) + ) + end + + def transaction_id(task_id:) + body = URI.encode_www_form({ task_id: task_id, nonce: nonce }) + safely_parse http_request( + url: "#{API_URL}/withdraw_get_txid", + method: :POST, + body: body, + headers: build_headers(build_signature(body)) + ) + end + + private + + attr_reader :public_key, :secret_key + + def build_headers(signature) + { + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Key' => public_key, + 'Sign' => signature + } + end + + def nonce + Time.now.strftime("%s%6N") + end + + def build_signature(request_body) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha512'), secret_key, request_body) + end + end +end diff --git a/lib/payment_services/exmo/invoice.rb b/lib/payment_services/exmo/invoice.rb new file mode 100644 index 00000000..f291a991 --- /dev/null +++ b/lib/payment_services/exmo/invoice.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class PaymentServices::Exmo + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + self.table_name = 'exmo_invoices' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + + validates :amount_cents, :order_public_id, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + event :cancel, transitions_to: :cancelled + end + + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount, hash: transaction_id) + end + end + state :cancelled + end + + def update_state_by_provider(state) + update!(provider_state: state) + + pay! if success? + cancel! if failed? + end + + def order + Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + end + + private + + def success? + transaction_id.present? + end + + def failed? + provider_state == 'Cancelled' || provider_state == 'Error' + end + end +end diff --git a/lib/payment_services/exmo/invoicer.rb b/lib/payment_services/exmo/invoicer.rb new file mode 100644 index 00000000..f93e9668 --- /dev/null +++ b/lib/payment_services/exmo/invoicer.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Exmo + class Invoicer < ::PaymentServices::Base::Invoicer + TRANSACTION_TIME_THRESHOLD = 30.minutes + WalletOperationsRequestFailed = Class.new StandardError + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + response = client.wallet_operations(currency: invoice.amount_currency, type: 'deposit') + raise WalletOperationsRequestFailed, "Can't get wallet operations" unless response['items'] + + transaction = find_transaction(transactions: response['items']) + return if transaction.nil? + + invoice.update!( + transaction_created_at: DateTime.strptime(transaction['created'].to_s,'%s').utc, + transaction_id: transaction.dig('extra', 'txid') + ) + invoice.update_state_by_provider(transaction['status']) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def find_transaction(transactions:) + transactions.find { |transaction| matches_amount_and_timing?(transaction) } + end + + def matches_amount_and_timing?(transaction) + transaction['amount'].to_d == invoice.amount.to_d && match_time_interval?(transaction) + end + + def match_time_interval?(transaction) + transaction_created_at_utc = DateTime.strptime(transaction['created'].to_s,'%s').utc + invoice_created_at_utc = invoice.created_at.utc + + invoice_created_at_utc < transaction_created_at_utc && created_in_valid_interval?(transaction_created_at_utc, invoice_created_at_utc) + end + + def created_in_valid_interval?(transaction_time, invoice_time) + interval = (transaction_time - invoice_time) + interval_in_minutes = (interval / 1.minute).round.minutes + interval_in_minutes < TRANSACTION_TIME_THRESHOLD + end + + def client + @client ||= Client.new(public_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/exmo/payout.rb b/lib/payment_services/exmo/payout.rb new file mode 100644 index 00000000..53719a35 --- /dev/null +++ b/lib/payment_services/exmo/payout.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class PaymentServices::Exmo + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + self.table_name = 'exmo_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, presence: true + + alias_attribute :txid, :transaction_id + + delegate :order, to: :order_payout + delegate :outcome_payment_system, to: :order + delegate :token_network, to: :outcome_payment_system + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(task_id:) + update(task_id: task_id) + end + + def order_fio + order_payout.order.outcome_fio.presence || order_payout.order.outcome_unk + end + + def update_payout_details!(transaction:) + update!( + provider_state: transaction.provider_state, + transaction_id: transaction.id + ) + + confirm! if transaction.successful? + fail! if transaction.failed? + end + + private + + def order_payout + @order_payout ||= OrderPayout.find(order_payout_id) + end + end +end diff --git a/lib/payment_services/exmo/payout_adapter.rb b/lib/payment_services/exmo/payout_adapter.rb new file mode 100644 index 00000000..c776708d --- /dev/null +++ b/lib/payment_services/exmo/payout_adapter.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' +require_relative 'transaction' + +class PaymentServices::Exmo + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + INVOICED_CURRENCIES = %w[xrp xem xlm] + Error = Class.new StandardError + PayoutCreateRequestFailed = Class.new Error + WalletOperationsRequestFailed = Class.new Error + + delegate :outcome_transaction_fee_amount, to: :payment_system + delegate :neo?, :usdt?, to: :currency, prefix: true + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + response = client.wallet_operations(currency: currency.upcase, type: 'withdrawal') + raise WalletOperationsRequestFailed, "Can't get wallet operations" unless response['items'] + + raw_transaction = find_transaction_of(payout: payout, transactions: response['items']) + return if raw_transaction.nil? + + transaction = Transaction.build_from(raw_transaction: raw_transaction) + transaction.id = client.transaction_id(task_id: payout.task_id)['txid'] + payout.update_payout_details!(transaction: transaction) + transaction + end + + private + + def make_payout(amount:, destination_account:, order_payout_id:) + payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + payout_params = { + amount: amount.to_d + (outcome_transaction_fee_amount || 0), + currency: currency.upcase, + address: destination_account + } + if invoice_required? + payout_params[:invoice] = payout.order_fio.present? ? payout.order_fio : '1' + end + payout_params[:amount] = payout_params[:amount].to_i if currency_neo? + payout_params[:transport] = payout.token_network.upcase if currency_usdt? + response = client.create_payout(params: payout_params) + raise PayoutCreateRequestFailed, "Can't create payout: #{response['error']}" unless response['result'] + + payout.pay!(task_id: response['task_id'].to_i) + end + + def find_transaction_of(payout:, transactions:) + transactions.find do |transaction| + transaction['order_id'] == payout.task_id && (payout.amount.to_d + (outcome_transaction_fee_amount || 0)) == transaction['amount'].to_d + end + end + + def client + @client ||= begin + Client.new(public_key: api_key, secret_key: api_secret) + end + end + + def invoice_required? + INVOICED_CURRENCIES.include?(currency) + end + + def currency + @currency ||= begin + cur = wallet.currency.to_s.downcase + cur = 'dash' if cur == 'dsh' + cur.inquiry + end + end + end +end diff --git a/lib/payment_services/exmo/transaction.rb b/lib/payment_services/exmo/transaction.rb new file mode 100644 index 00000000..ffd27db6 --- /dev/null +++ b/lib/payment_services/exmo/transaction.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class PaymentServices::Exmo + class Transaction + include Virtus.model + + SUCCESSFULL_PROVIDER_STATE = 'Paid' + FAILED_PROVIDER_STATES = %w(Cancelled Error) + + attribute :id, String + attribute :provider_state, Integer + attribute :source, String + + def self.build_from(raw_transaction:) + new( + id: raw_transaction['extra']['txid'], + provider_state: raw_transaction['status'], + source: raw_transaction + ) + end + + def to_s + source.to_s + end + + def successful? + provider_state == SUCCESSFULL_PROVIDER_STATE + end + + def failed? + FAILED_PROVIDER_STATES.include?(provider_state) + end + end +end diff --git a/lib/payment_services/ff.rb b/lib/payment_services/ff.rb new file mode 100644 index 00000000..a59c453f --- /dev/null +++ b/lib/payment_services/ff.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class Ff < Base + autoload :Invoicer, 'payment_services/ff/invoicer' + autoload :PayoutAdapter, 'payment_services/ff/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/ff/client.rb b/lib/payment_services/ff/client.rb new file mode 100644 index 00000000..c30ef7af --- /dev/null +++ b/lib/payment_services/ff/client.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class PaymentServices::Ff + class Client < ::PaymentServices::Base::Client + API_URL = 'https://ff.io/api/v2' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/create", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def transaction(params:) + safely_parse http_request( + url: "#{API_URL}/order", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def ccies + safely_parse http_request( + url: "#{API_URL}/ccies", + method: :POST, + body: {}.to_json, + headers: build_headers(signature: build_signature({})) + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers(signature:) + { + 'Accept' => 'application/json', + 'X-API-KEY' => api_key, + 'X-API-SIGN' => signature, + 'Content-Type' => 'application/json; charset=UTF-8' + } + end + + def build_signature(params) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret_key, params.to_json) + end + end +end diff --git a/lib/payment_services/ff/invoice.rb b/lib/payment_services/ff/invoice.rb new file mode 100644 index 00000000..2a6587ff --- /dev/null +++ b/lib/payment_services/ff/invoice.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class PaymentServices::Ff + class Invoice < ::PaymentServices::Base::CryptoInvoice + self.table_name = 'ff_invoices' + + monetize :amount_cents, as: :amount + + def update_state_by_transaction(transaction) + bind_transaction! if pending? + update!( + transaction_id: transaction.id, + provider_state: transaction.status + ) + + pay!(payload: transaction) if transaction.income_succeed? + cancel! if transaction.failed? + end + + def transaction_created_at + nil + end + end +end diff --git a/lib/payment_services/ff/invoicer.rb b/lib/payment_services/ff/invoicer.rb new file mode 100644 index 00000000..dfd7a6d1 --- /dev/null +++ b/lib/payment_services/ff/invoicer.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative 'client' +require_relative 'invoice' +require_relative 'transaction' + +class PaymentServices::Ff + class Invoicer < ::PaymentServices::Base::Invoicer + Error = Class.new StandardError + SUCCESS_REQUEST_STATUS_CODE = 0 + FEE_IN_PERCENTS = 0.5 + REF_CODE = 'jznep39b' + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_invoice(params: invoice_params) + raise Error, "Can't create invoice: #{response['msg']}" if response['code'] != SUCCESS_REQUEST_STATUS_CODE + + invoice.update!(deposit_id: response.dig('data', 'id'), access_token: response.dig('data', 'token')) + PaymentServices::Base::Wallet.new( + address: response['data']['from']['address'], + name: nil, + memo: response['data']['from']['tag'].presence + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + raw_transaction = client.transaction(params: { id: invoice.deposit_id, token: invoice.access_token }) + transaction = Transaction.build_from(raw_transaction['data']) + invoice.update_state_by_transaction(transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_params + type = order.exchange_rate_flexible_rate? && order.flexible_rate? ? 'float' : 'fixed' + from = order.income_currency.to_s + from = 'BSC' if from == 'BNB' + params = { + type: type, + fromCcy: from, + toCcy: order.outcome_currency.to_s, + direction: 'from', + amount: invoice.amount.to_f, + toAddress: order.outcome_account, + refcode: REF_CODE, + afftax: FEE_IN_PERCENTS + } + params[:tag] = order.outcome_unk if order.outcome_unk.present? + params + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/ff/payout.rb b/lib/payment_services/ff/payout.rb new file mode 100644 index 00000000..e6a45376 --- /dev/null +++ b/lib/payment_services/ff/payout.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class PaymentServices::Ff + class Payout < ::PaymentServices::Base::CryptoPayout + self.table_name = 'ff_payouts' + + monetize :amount_cents, as: :amount + + def txid + transaction_id + end + + def update_state_by_provider!(transaction) + update!( + transaction_id: transaction.id, + provider_state: transaction.status + ) + + confirm! if transaction.outcome_succeed? + fail! if transaction.failed? + end + end +end diff --git a/lib/payment_services/ff/payout_adapter.rb b/lib/payment_services/ff/payout_adapter.rb new file mode 100644 index 00000000..ba0569ca --- /dev/null +++ b/lib/payment_services/ff/payout_adapter.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require_relative 'client' +require_relative 'payout' +require_relative 'invoice' +require_relative 'transaction' + +class PaymentServices::Ff + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + @payout_id = payout_id + return if payout.pending? + + raw_transaction = client.transaction(params: { id: payout.withdrawal_id, token: payout.access_token }) + transaction = Transaction.build_from(raw_transaction['data'], direction: :to) + payout.update_state_by_provider!(transaction) + + transaction + end + + def payout + @payout ||= Payout.find_by(id: payout_id) + end + + private + + attr_accessor :payout_id + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout_id = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id).id + + invoice = Invoice.find_by!(order_public_id: OrderPayout.find(payout.order_payout_id).order.public_id) + + payout.pay!(withdrawal_id: invoice.deposit_id) + payout.update!(access_token: invoice.access_token) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/ff/transaction.rb b/lib/payment_services/ff/transaction.rb new file mode 100644 index 00000000..6e75f843 --- /dev/null +++ b/lib/payment_services/ff/transaction.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class PaymentServices::Ff + class Transaction + SUCCESS_INCOME_PROVIDER_STATE = 'EXCHANGE' + SUCCESS_OUTCOME_PROVIDER_STATE = 'DONE' + FAILED_PROVIDER_STATE = 'EXPIRED' + DELAY = 10.minutes + + include Virtus.model + + attribute :id, String + attribute :status, String + attribute :source, Hash + + def self.build_from(raw_transaction, direction: :from) + new( + status: raw_transaction['status'], + id: raw_transaction[direction.to_s]['tx']['id'], + source: raw_transaction + ) + end + + def to_s + source.to_s + end + + def income_succeed? + status == SUCCESS_INCOME_PROVIDER_STATE || status == SUCCESS_OUTCOME_PROVIDER_STATE + end + + def outcome_succeed? + status == SUCCESS_OUTCOME_PROVIDER_STATE && source['time']['finish'].present? && Time.at(source['time']['finish']) + DELAY < Time.current + end + + def failed? + status == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/fire_kassa.rb b/lib/payment_services/fire_kassa.rb new file mode 100644 index 00000000..427e8c38 --- /dev/null +++ b/lib/payment_services/fire_kassa.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class FireKassa < Base + autoload :Invoicer, 'payment_services/fire_kassa/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/fire_kassa/client.rb b/lib/payment_services/fire_kassa/client.rb new file mode 100644 index 00000000..81fbc204 --- /dev/null +++ b/lib/payment_services/fire_kassa/client.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class PaymentServices::FireKassa + class Client < ::PaymentServices::Base::Client + API_URL = 'https://admin.vanilapay.com/api/v2' + + def initialize(api_key:) + @api_key = api_key + end + + def create_card_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/deposit/card", + method: :POST, + body: params.to_json, + headers: build_headers + ) + end + + def create_sbp_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/deposit/sbp-a", + method: :POST, + body: params.to_json, + headers: build_headers + ) + end + + def transaction(transaction_id:) + safely_parse http_request( + url: "#{API_URL}/transactions/#{transaction_id}", + method: :GET, + headers: build_headers + ) + end + + private + + attr_reader :api_key + + def build_headers + { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{api_key}" + } + end + end +end diff --git a/lib/payment_services/fire_kassa/invoice.rb b/lib/payment_services/fire_kassa/invoice.rb new file mode 100644 index 00000000..4be6ffa4 --- /dev/null +++ b/lib/payment_services/fire_kassa/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::FireKassa + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATES = %w(paid overpaid) + FAILED_PROVIDER_STATE = 'expired' + + self.table_name = 'fire_kassa_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state.in? SUCCESS_PROVIDER_STATES + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/fire_kassa/invoicer.rb b/lib/payment_services/fire_kassa/invoicer.rb new file mode 100644 index 00000000..db01a39c --- /dev/null +++ b/lib/payment_services/fire_kassa/invoicer.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::FireKassa + class Invoicer < ::PaymentServices::Base::Invoicer + Error = Class.new StandardError + DEFAULT_CARD = 'sber' + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = sbp? ? client.create_sbp_invoice(params: invoice_params) : client.create_card_invoice(params: invoice_params) + raise Error, "Can't create invoice: #{response['message']}" if response['message'] + + invoice.update!(deposit_id: response['id']) + PaymentServices::Base::Wallet.new( + address: response['card_number'] || format_phone_number(response['account']), + name: [response['first_name'].capitalize, response['last_name'].capitalize].join(' '), + memo: response['bank'].capitalize + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.transaction(transaction_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction['status']) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :card_bank, :sbp_bank, :sbp?, to: :bank_resolver + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_params + params = { + account: order.income_account, + order_id: order.public_id.to_s, + site_account: DEFAULT_CARD, + amount: invoice.amount.to_f.to_s, + comment: "Order ##{order.public_id.to_s}", + ext_txn: order.public_id.to_s, + ext_date: order.created_at.iso8601 + } + params[:ext_last_name] = order.user.aml_client.surname if order&.user&.aml_client&.surname.present? + params[:ext_first_name] = order.user.aml_client.first_name if order&.user&.aml_client&.first_name.present? + params[:ext_email] = order.user_email if order&.user&.email.present? + params[:ext_ip] = order.remote_ip if order.remote_ip.present? + params[:ext_user_agent] = order.user.user_agent if order&.user&.user_agent.present? + params[:ext_photo] = CardVerification.find_by(order_public_id: order.public_id).image.url if CardVerification.find_by(order_public_id: order.public_id)&.image&.url.present? + params[:bank_id] = sbp_bank if sbp? + params + end + + def bank_resolver + @bank_resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def client + @client ||= Client.new(api_key: api_key) + end + + def format_phone_number(account) + "+7 (#{account[0..2]}) #{account[3..5]}-#{account[6..7]}-#{account[8..9]}" + end + end +end diff --git a/lib/payment_services/just_pays.rb b/lib/payment_services/just_pays.rb new file mode 100644 index 00000000..0514e64d --- /dev/null +++ b/lib/payment_services/just_pays.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class JustPays < Base + autoload :Invoicer, 'payment_services/just_pays/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/just_pays/client.rb b/lib/payment_services/just_pays/client.rb new file mode 100644 index 00000000..22c55d82 --- /dev/null +++ b/lib/payment_services/just_pays/client.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class PaymentServices::JustPays + class Client < ::PaymentServices::Base::Client + API_URL = 'https://merchant-api.just-pays.com/api' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/payment_url", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def transactions + safely_parse http_request( + url: "#{API_URL}/order_history", + method: :POST, + body: {}.to_json, + headers: build_headers(signature: build_signature) + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_signature(params = {}) + OpenSSL::HMAC.hexdigest('SHA512', secret_key, params.to_json) + end + + def build_headers(signature:) + { + 'Content-Type' => 'application/json', + 'X-API-Key' => api_key, + 'X-API-Sign' => signature + } + end + end +end diff --git a/lib/payment_services/just_pays/invoice.rb b/lib/payment_services/just_pays/invoice.rb new file mode 100644 index 00000000..b3537e12 --- /dev/null +++ b/lib/payment_services/just_pays/invoice.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class PaymentServices::JustPays + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'OrderCompleted' + FAILED_PROVIDER_STATE = 'OrderCanceled' + + self.table_name = 'just_pays_invoices' + + monetize :amount_cents, as: :amount + + def can_be_confirmed?(income_money:) + pending? && income_money == amount + end + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/just_pays/invoicer.rb b/lib/payment_services/just_pays/invoicer.rb new file mode 100644 index 00000000..f77b4cb5 --- /dev/null +++ b/lib/payment_services/just_pays/invoicer.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::JustPays + class Invoicer < ::PaymentServices::Base::Invoicer + Error = Class.new StandardError + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_invoice(params: invoice_params) + + raise Error, "Can't create invoice: #{response['error']}" if response['error'] + + invoice.update!( + deposit_id: response['internal_id'], + pay_url: response['payment_url'] + ) + end + + def pay_invoice_url + invoice.present? ? URI.parse(invoice.reload.pay_url) : '' + end + + def async_invoice_state_updater? + false + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def invoice_params + { + external_id: order.public_id.to_s, + external_meta: { + uid: order.user_id.to_s, + ip: order.remote_ip, + email: order.user_email + }, + currency_symbol: invoice.amount_currency.to_s, + region_code: invoice.amount_currency.to_s.first(2), + gross_amount: format('%.2f', invoice.amount.to_f), + success_url: order.success_redirect, + failed_url: order.failed_redirect, + callback_url: "#{routes_helper.public_public_callbacks_api_root_url}/v1/just_pays/receive_payment" + } + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/kuna.rb b/lib/payment_services/kuna.rb new file mode 100644 index 00000000..acf36f02 --- /dev/null +++ b/lib/payment_services/kuna.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class Kuna < Base + autoload :Invoicer, 'payment_services/kuna/invoicer' + autoload :PayoutAdapter, 'payment_services/kuna/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/kuna/client.rb b/lib/payment_services/kuna/client.rb new file mode 100644 index 00000000..4448c4b3 --- /dev/null +++ b/lib/payment_services/kuna/client.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +class PaymentServices::Kuna + class Client + include AutoLogger + TIMEOUT = 30 + API_URL = 'https://api.kuna.io' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_deposit(params:) + safely_parse http_request( + url: API_URL + '/v3/auth/merchant/deposit', + method: :POST, + body: params + ) + end + + def create_payout(params:) + safely_parse http_request( + url: API_URL + '/v3/auth/withdraw', + method: :POST, + body: params + ) + end + + def payout_status(params:) + safely_parse http_request( + url: API_URL + '/v3/auth/withdraw/details', + method: :POST, + body: params + ) + end + + private + + attr_reader :api_key, :secret_key + + def http_request(url:, method:, body: nil) + uri = URI.parse(url) + https = http(uri) + request = build_request(uri: uri, method: method, body: body) + logger.info "Request type: #{method} to #{uri} with payload #{request.body}" + https.request(request) + end + + def build_request(uri:, method:, body: nil) + request = if method == :POST + Net::HTTP::Post.new(uri.request_uri, headers(uri.to_s, body)) + elsif method == :GET + Net::HTTP::Get.new(uri.request_uri) + else + raise "Запрос #{method} не поддерживается!" + end + request.body = (body.present? ? body : {}).to_json + request + end + + def headers(url, params) + nonce = time_now_milliseconds + + { + 'Content-Type' => 'application/json', + 'kun-nonce' => nonce, + 'kun-apikey' => api_key, + 'kun-signature' => signature(url: url, params: params, nonce: nonce) + } + end + + def time_now_milliseconds + Time.now.strftime("%s%3N") + end + + def http(uri) + Net::HTTP.start(uri.host, uri.port, + use_ssl: true, + verify_mode: OpenSSL::SSL::VERIFY_NONE, + open_timeout: TIMEOUT, + read_timeout: TIMEOUT) + end + + def signature(url:, params:, nonce:) + url.slice!(API_URL) + sign_string = url + nonce + params.to_json + + OpenSSL::HMAC.hexdigest('SHA384', secret_key, sign_string) + end + + def safely_parse(response) + res = JSON.parse(response.body) + logger.info "Response: #{res}" + res + rescue JSON::ParserError => err + logger.warn "Request failed #{response.class} #{response.body}" + Bugsnag.notify err do |report| + report.add_tab(:response, response_class: response.class, response_body: response.body) + end + response.body + end + end +end diff --git a/lib/payment_services/kuna/invoice.rb b/lib/payment_services/kuna/invoice.rb new file mode 100644 index 00000000..2529c74a --- /dev/null +++ b/lib/payment_services/kuna/invoice.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class PaymentServices::Kuna + class Invoice < PaymentServices::ApplicationRecord + FEE_PERCENT = 0.5 + UAH_FEE_PERCENT = 1.0 + UAH_FEE_REGULAR = 5 + KOPECK_EPSILON = 1 + + include WorkflowActiverecord + + self.table_name = 'kuna_invoices' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + + validates :amount_cents, :order_public_id, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + event :cancel, transitions_to: :cancelled + end + + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount, hash: deposit_id) + end + end + state :cancelled + end + + def can_be_confirmed?(income_money:) + pending? && amount_matches?(income_money) + end + + def pay(payload:) + update(payload: payload) + end + + def order + Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + end + + private + + def amount_matches?(income_amount) + (amount - income_amount - fee) <= Money.new(KOPECK_EPSILON, amount.currency) + end + + def fee + if amount_currency == 'UAH' + amount * UAH_FEE_PERCENT / 100 + Money.from_amount(UAH_FEE_REGULAR, amount_currency) + else + amount * FEE_PERCENT / 100 + end + end + end +end diff --git a/lib/payment_services/kuna/invoicer.rb b/lib/payment_services/kuna/invoicer.rb new file mode 100644 index 00000000..2fbcdd0c --- /dev/null +++ b/lib/payment_services/kuna/invoicer.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Kuna + class Invoicer < ::PaymentServices::Base::Invoicer + PAY_URL = 'https://paygate.kuna.io/hpp' + + def create_invoice(money) + invoice = Invoice.create!(amount: money, order_public_id: order.public_id) + + params = { + amount: invoice.amount.to_f, + currency: currency, + payment_service: payment_service, + return_url: order.success_redirect, + callback_url: order.income_payment_system.callback_url + } + + params[:fields] = { required_field_name => order.income_account } unless payment_card_uah? + + response = client.create_deposit(params: params) + + raise "Can't create invoice: #{response['messages']}" if response['messages'] + + invoice.update!( + deposit_id: response['deposit_id'], + payment_invoice_id: response['payment_invoice_id'] + ) + end + + def pay_invoice_url + uri = URI.parse(PAY_URL) + uri.query = { cpi: invoice.reload.payment_invoice_id }.to_query + + uri + end + + private + + def payment_card_uah? + payway == 'visamc' && currency == 'uah' + end + + def currency + @currency ||= invoice.amount.currency.to_s.downcase + end + + def invoice + @invoice ||= Invoice.find_by!(order_public_id: order.public_id) + end + + def payway + @payway ||= order.income_wallet.payment_system.payway + end + + def payment_service + available_options = { + 'visamc' => "payment_card_#{currency}_hpp", + 'qiwi' => "qiwi_#{currency}_hpp" + } + available_options[payway] + end + + def required_field_name + required_field_for = { + 'visamc' => 'card_number', + 'qiwi' => 'phone' + } + required_field_for[payway] + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/kuna/payout.rb b/lib/payment_services/kuna/payout.rb new file mode 100644 index 00000000..181a7063 --- /dev/null +++ b/lib/payment_services/kuna/payout.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PaymentServices::Kuna + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + self.table_name = 'kuna_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(withdrawal_id:) + update(withdrawal_id: withdrawal_id) + end + + def success? + provider_state == 'done' + end + + def status_failed? + provider_state == 'canceled' || provider_state == 'unknown' + end + end +end diff --git a/lib/payment_services/kuna/payout_adapter.rb b/lib/payment_services/kuna/payout_adapter.rb new file mode 100644 index 00000000..90bb4d3b --- /dev/null +++ b/lib/payment_services/kuna/payout_adapter.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::Kuna + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + DEFAULT_GATEWAY = 'default' + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + @payout_id = payout_id + return if payout.pending? + + response = client.payout_status(params: { id: payout.withdrawal_id }) + + raise "Can't get withdrawal details: #{response['messages']}" if response['messages'] + + payout.update!(provider_state: response['status']) if response['status'] + payout.confirm! if payout.success? + payout.fail! if payout.status_failed? + + response + end + + def payout + @payout ||= Payout.find_by(id: payout_id) + end + + private + + attr_accessor :payout_id + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout_id = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id).id + + params = { + amount: amount.to_d, + withdraw_type: currency, + withdraw_to: destination_account, + gateway: gateway + } + response = client.create_payout(params: params) + # NOTE: API returns an array of responses + response = response.first if response.is_a? Array + + raise "Can't process payout: #{response['messages']}" if response['messages'] + + payout.pay!(withdrawal_id: response['withdrawal_id']) if response['withdrawal_id'] + end + + def client + @client ||= begin + Client.new(api_key: api_key, secret_key: api_secret) + end + end + + def gateway + return DEFAULT_GATEWAY unless wallet.payment_system.name == 'QIWI' + + "qiwi_#{currency}" + end + + def currency + @currency ||= wallet.currency.to_s.downcase + end + end +end diff --git a/lib/payment_services/liquid.rb b/lib/payment_services/liquid.rb new file mode 100644 index 00000000..0c5a2afe --- /dev/null +++ b/lib/payment_services/liquid.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class Liquid < Base + autoload :Invoicer, 'payment_services/liquid/invoicer' + autoload :PayoutAdapter, 'payment_services/liquid/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/liquid/client.rb b/lib/payment_services/liquid/client.rb new file mode 100644 index 00000000..34128724 --- /dev/null +++ b/lib/payment_services/liquid/client.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +class PaymentServices::Liquid + class Client + include AutoLogger + TIMEOUT = 10 + API_URL = 'https://api.liquid.com' + API_VERSION = '2' + + def initialize(currency:, token_id:, api_key:) + @currency = currency + @token_id = token_id + @api_key = api_key + end + + def address_transactions + params = { + transaction_type: 'funding', + currency: currency + } + + request_for('/transactions?', params: params) + end + + def wallet + wallets = request_for('/crypto_accounts') + + wallets.find { |w| w['currency'] == currency } + end + + def make_payout(params) + safely_parse http_request( + url: API_URL + "/crypto_withdrawals", + method: :POST, + body: { 'crypto_withdrawal' => params.merge(currency: currency) } + ) + end + + def withdrawals + request_for('/crypto_withdrawals?', params: { currency: currency }) + end + + private + + attr_reader :currency, :token_id, :api_key + + def request_for(path, params: nil) + url = API_URL + path + url += params.to_query if params + + safely_parse http_request( + url: url, + method: :GET + ) + end + + def http_request(url:, method:, body: nil) + uri = URI.parse(url) + https = http(uri) + request = build_request(uri: uri, method: method, body: body) + logger.info "Request type: #{method} to #{uri} with payload #{request.body}" + https.request(request) + end + + def build_request(uri:, method:, body: nil) + request = if method == :POST + Net::HTTP::Post.new(uri.request_uri, headers(uri.to_s.delete_prefix(API_URL))) + elsif method == :GET + Net::HTTP::Get.new(uri.request_uri, headers(uri.to_s.delete_prefix(API_URL))) + else + raise "Запрос #{method} не поддерживается!" + end + request.body = (body.present? ? body : {}).to_json + request + end + + def http(uri) + Net::HTTP.start(uri.host, uri.port, + use_ssl: true, + verify_mode: OpenSSL::SSL::VERIFY_NONE, + open_timeout: TIMEOUT, + read_timeout: TIMEOUT) + end + + def headers(path) + { + 'Content-Type': 'application/json', + 'X-Quoine-API-Version': API_VERSION, + 'X-Quoine-Auth': build_signature(path) + } + end + + def build_signature(path) + auth_payload = { + path: path, + nonce: DateTime.now.strftime('%Q'), + token_id: token_id + } + + JWT.encode(auth_payload, api_key, 'HS256') + end + + def safely_parse(response) + res = JSON.parse(response.body) + logger.info "Response: #{res}" + res + rescue JSON::ParserError => err + logger.warn "Request failed #{response.class} #{response.body}" + Bugsnag.notify err do |report| + report.add_tab(:response, response_class: response.class, response_body: response.body) + end + response.body + end + end +end diff --git a/lib/payment_services/liquid/invoice.rb b/lib/payment_services/liquid/invoice.rb new file mode 100644 index 00000000..34a3f8e8 --- /dev/null +++ b/lib/payment_services/liquid/invoice.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class PaymentServices::Liquid + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord + self.table_name = 'liquid_invoices' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :order_public_id, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + event :cancel, transitions_to: :cancelled + end + + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount, hash: transaction_id) + end + end + state :cancelled + end + + def complete_payment? + provider_state == 'confirmed' + end + + def pay(payload:) + update(payload: payload) + end + + def order + @order ||= Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + end + end +end diff --git a/lib/payment_services/liquid/invoicer.rb b/lib/payment_services/liquid/invoicer.rb new file mode 100644 index 00000000..163e9ce7 --- /dev/null +++ b/lib/payment_services/liquid/invoicer.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Liquid + class Invoicer < ::PaymentServices::Base::Invoicer + WALLET_NAME_GROUP = 'LIQUID_API_KEYS' + AddressTransactionsRequestFailed = Class.new StandardError + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id, address: order.income_account_emoney) + end + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + response = Client.new(currency: currency, token_id: api_wallet.merchant_id.to_i, api_key: api_key).wallet + + PaymentServices::Base::Wallet.new(address: response['address'].first, name: response['address'].last) + end + + def update_invoice_state! + transaction = transaction_for(invoice) + return if transaction.nil? + + update_invoice_details(transaction: transaction) + invoice.pay!(payload: transaction) if invoice.complete_payment? + end + + def async_invoice_state_updater? + true + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def api_wallet + @api_wallet ||= Wallet.find_by(name_group: WALLET_NAME_GROUP) + end + + def update_invoice_details(transaction:) + invoice.transaction_created_at ||= DateTime.strptime(transaction['created_at'].to_s, '%s').utc + invoice.transaction_id ||= transaction['transaction_hash'] + invoice.provider_state = transaction['state'] + + invoice.save! + end + + def transaction_for(invoice) + response = client.address_transactions + raise AddressTransactionsRequestFailed if response['message'] + return unless response['models'] + + response['models'].find do |transaction| + received_amount = transaction['gross_amount'] + received_amount.to_d == invoice.amount.to_d && DateTime.strptime(transaction['created_at'].to_s, '%s').utc > invoice.created_at.utc + end + end + + def client + @client ||= Client.new(currency: order.income_wallet.currency.to_s, token_id: api_wallet.merchant_id.to_i, api_key: api_key) + end + end +end diff --git a/lib/payment_services/liquid/payout.rb b/lib/payment_services/liquid/payout.rb new file mode 100644 index 00000000..755bbcc3 --- /dev/null +++ b/lib/payment_services/liquid/payout.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PaymentServices::Liquid + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + self.table_name = 'liquid_payouts' + + SUCCESS_PAYOUT_STATE = 'processed' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :address, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + end + state :completed + state :failed + end + + def pay(withdrawal_id:) + update(withdrawal_id: withdrawal_id) + end + + def complete_payout? + status == SUCCESS_PAYOUT_STATE + end + + def txid + withdrawal_id + end + end +end diff --git a/lib/payment_services/liquid/payout_adapter.rb b/lib/payment_services/liquid/payout_adapter.rb new file mode 100644 index 00000000..446fdf00 --- /dev/null +++ b/lib/payment_services/liquid/payout_adapter.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::Liquid + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + WALLET_NAME_GROUP = 'LIQUID_API_KEYS' + + delegate :outcome_transaction_fee_amount, to: :payment_system + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + address: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + @payout_id = payout_id + return if payout.pending? + + response = client.withdrawals + raise "Can't get payout details: #{response['errors'].to_s}" if response['errors'] + + withdrawal = response['models'].find do |withdrawal| + payout.withdrawal_id == withdrawal['id'] + end + + payout.update!(status: withdrawal['state']) if withdrawal + payout.confirm! if payout.complete_payout? + + withdrawal + end + + def payout + @payout ||= Payout.find_by(id: payout_id) + end + + private + + attr_accessor :payout_id + + def api_wallet + @api_wallet ||= Wallet.find_by(name_group: WALLET_NAME_GROUP) + end + + def make_payout(amount:, address:, order_payout_id:) + @payout_id = Payout.create!(amount: amount, address: address, order_payout_id: order_payout_id).id + + payout_params = { + amount: amount.to_d.round(2) + (outcome_transaction_fee_amount || 0), + address: address, + payment_id: nil, + memo_type: nil, + memo_value: nil + } + response = client.make_payout(payout_params) + + # NOTE: there are 2 types of error responses + errors = response['message'] || response['errors'] + raise "Can't process payout: #{errors.to_s}" if errors + raise 'Payout was not processed' unless response['id'] + + payout.pay!(withdrawal_id: response['id']) + end + + def client + @client ||= Client.new(currency: wallet.currency.to_s, token_id: api_wallet.merchant_id.to_i, api_key: api_key) + end + end +end diff --git a/lib/payment_services/manual_by_group.rb b/lib/payment_services/manual_by_group.rb new file mode 100644 index 00000000..92168b85 --- /dev/null +++ b/lib/payment_services/manual_by_group.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class ManualByGroup < Base + autoload :Invoicer, 'payment_services/manual_by_group/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/manual_by_group/invoicer.rb b/lib/payment_services/manual_by_group/invoicer.rb new file mode 100644 index 00000000..af628fa6 --- /dev/null +++ b/lib/payment_services/manual_by_group/invoicer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class PaymentServices::ManualByGroup + class Invoicer < ::PaymentServices::Base::Invoicer + def prepare_invoice_and_get_wallet!(currency:, token_network:) + wallet = income_payment_system.select_next_wallet!(income_payment_system.wallets_available_for_transfers.income.where(name_group: income_payment_system.wallets_name_group.presence)) + wallet = income_payment_system.select_next_wallet!(wallet.incoming_children_wallets) if wallet && wallet.incoming_children_wallets.any? + PaymentServices::Base::Wallet.new( + address: wallet&.account, + name: wallet&.name, + memo: wallet&.memo, + name_group: wallet&.name_group + ) + end + + def create_invoice(money) + true + end + + def async_invoice_state_updater? + false + end + + def invoice + nil + end + + private + + delegate :wallets_name_group, :wallets_available_for_transfers, to: :income_payment_system + delegate :income_payment_system, to: :order + end +end diff --git a/lib/payment_services/master_processing.rb b/lib/payment_services/master_processing.rb new file mode 100644 index 00000000..26a4bdfc --- /dev/null +++ b/lib/payment_services/master_processing.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class MasterProcessing < Base + autoload :Invoicer, 'payment_services/master_processing/invoicer' + autoload :PayoutAdapter, 'payment_services/master_processing/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/master_processing/client.rb b/lib/payment_services/master_processing/client.rb new file mode 100644 index 00000000..c7cfc274 --- /dev/null +++ b/lib/payment_services/master_processing/client.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'base64' + +class PaymentServices::MasterProcessing + class Client < ::PaymentServices::Base::Client + API_URL = 'https://masterprocessingvip.ru/api/payment' + SHARED_PUBLIC_KEY = "04d08e67c1371b7201aabf03b933c23b540cce0c007a59137f50d70bb4cc5ebd860344af03a47b6bb503b05952200d264c5f8fee57d54da40cd38cb7b004c629c5" + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:, payway:) + safely_parse http_request( + url: create_invoice_endpoint(payway), + method: :POST, + body: params.to_json, + headers: build_headers(build_signature(params)) + ) + end + + def process_payout(endpoint:, params:) + params.merge!(HSID: generate_hsid(params)) + safely_parse http_request( + url: "#{API_URL}/#{endpoint}", + method: :POST, + body: params.to_json, + headers: build_headers(build_signature(params)) + ) + end + + def invoice_status(params:) + safely_parse http_request( + url: "#{API_URL}/get_invoice_order_info", + method: :POST, + body: params.to_json, + headers: build_headers(build_signature(params)) + ) + end + + def payout_status(params:) + safely_parse http_request( + url: "#{API_URL}/get_withdraw_order_info", + method: :POST, + body: params.to_json, + headers: build_headers(build_signature(params)) + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers(signature) + { + 'Content-Type' => 'application/json', + 'API-Key' => api_key, + 'Signature' => signature + } + end + + def generate_hsid(params) + data = params.to_json + public_key_bin = [SHARED_PUBLIC_KEY].pack('H*') + group = OpenSSL::PKey::EC::Group.new("prime256v1") + public_point = OpenSSL::PKey::EC::Point.new(group, OpenSSL::BN.new(public_key_bin, 2)) + key = OpenSSL::PKey::EC.new(group) + key.generate_key! + key.public_key = public_point + + Base64.encode64(key.dsa_sign_asn1(data)) + end + + def build_signature(request_body) + OpenSSL::HMAC.hexdigest('SHA512', secret_key, request_body.to_json) + end + + def create_invoice_endpoint(payway) + if payway.cardh2h? || payway.qiwih2h? + "#{API_URL}/generate_invoice_h2h" + else + "#{API_URL}/generate_p2p_v3" + end + end + end +end diff --git a/lib/payment_services/master_processing/invoice.rb b/lib/payment_services/master_processing/invoice.rb new file mode 100644 index 00000000..45bcb444 --- /dev/null +++ b/lib/payment_services/master_processing/invoice.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class PaymentServices::MasterProcessing + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + self.table_name = 'master_processing_invoices' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + + validates :amount_cents, :order_public_id, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + event :cancel, transitions_to: :cancelled + end + + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount, hash: deposit_id) + end + end + state :cancelled + end + + def order + Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + end + + def update_state_by_provider(status) + update!(provider_state: status) + + pay! if success? + cancel! if failed? + end + + private + + def success? + provider_state == 'payed' + end + + def failed? + provider_state == 'canceled' || provider_state == 'failed' + end + end +end diff --git a/lib/payment_services/master_processing/invoicer.rb b/lib/payment_services/master_processing/invoicer.rb new file mode 100644 index 00000000..9304c424 --- /dev/null +++ b/lib/payment_services/master_processing/invoicer.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' +require_relative 'response' + +class PaymentServices::MasterProcessing + class Invoicer < ::PaymentServices::Base::Invoicer + QIWI_DUMMY_CARD_TAIL = '9999' + AVAILABLE_PAYSOURCE_OPTIONS = { + 'visamc' => 'card', + 'cardh2h' => 'card', + 'qiwi' => 'qw', + 'qiwih2h' => 'qw' + } + + def create_invoice(money) + invoice = Invoice.create!(amount: money, order_public_id: order.public_id) + + params = { + amount: invoice.amount.to_i, + expireAt: PreliminaryOrder::MAX_LIVE.to_i, + comment: comment, + clientIP: client_ip, + paySourcesFilter: pay_source, + cardNumber: the_last_four_card_number, + email: order.email + } + + raw_response = client.create_invoice(params: params, payway: payway) + response = Response.build_from(raw_response: raw_response) + + raise "Can't create invoice: #{response.error_message}" unless response.success? + + invoice.update!( + deposit_id: response.deposit_id, + pay_invoice_url: response.pay_invoice_url + ) + end + + def pay_invoice_url + invoice.reload.pay_invoice_url if invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + response = client.invoice_status(params: { externalID: invoice.reload.deposit_id }) + raise "Can't get withdrawal details" unless response['statusName'] + + invoice.update_state_by_provider(response['statusName']) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + + def comment + "Order: #{order.public_id}" + end + + def client_ip + order.remote_ip || "" + end + + def payway + @payway ||= order.income_payment_system.payway.inquiry + end + + def pay_source + AVAILABLE_PAYSOURCE_OPTIONS[payway] + end + + def the_last_four_card_number + return QIWI_DUMMY_CARD_TAIL if payway_qiwi? + + order.income_account.last(4) + end + + def payway_qiwi? + payway.qiwi? || payway.qiwih2h? + end + end +end diff --git a/lib/payment_services/master_processing/payout.rb b/lib/payment_services/master_processing/payout.rb new file mode 100644 index 00000000..c98ecc28 --- /dev/null +++ b/lib/payment_services/master_processing/payout.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PaymentServices::MasterProcessing + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + self.table_name = 'master_processing_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(withdrawal_id:) + update(withdrawal_id: withdrawal_id) + end + + def success? + provider_state == 'success' + end + + def status_failed? + provider_state == 'canceled' || provider_state == 'failed' + end + end +end diff --git a/lib/payment_services/master_processing/payout_adapter.rb b/lib/payment_services/master_processing/payout_adapter.rb new file mode 100644 index 00000000..efbf7f33 --- /dev/null +++ b/lib/payment_services/master_processing/payout_adapter.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::MasterProcessing + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + PAYOUT_ACCEPTED_RESPONSE = 'Accepted' + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + @payout_id = payout_id + return if payout.pending? + + response = client.payout_status(params: { externalID: payout.withdrawal_id }) + + raise "Can't get withdrawal details" unless response['statusName'] + + payout.update!(provider_state: response['statusName']) + payout.confirm! if payout.success? + payout.fail! if payout.status_failed? + + response + end + + def payout + @payout ||= Payout.find_by(id: payout_id) + end + + private + + attr_accessor :payout_id + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout_id = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id).id + + params = { + amount: amount.to_i, + recipient: destination_account, + uid: order_payout_id.to_s, + callbackURL: wallet.payment_system.callback_url + } + response = client.process_payout(endpoint: endpoint, params: params) + raise "Can't process payout: #{response}" unless response['status'] == PAYOUT_ACCEPTED_RESPONSE + + payout.pay!(withdrawal_id: response['externalID']) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + + def endpoint + { + 'visamc' => 'withdraw_to_card_v2', + 'cardh2h' => 'withdraw_to_card_v2', + 'qiwi' => 'withdraw_to_qiwi_v2', + 'qiwih2h' => 'withdraw_to_qiwi_v2' + }[wallet.payment_system.payway] + end + end +end diff --git a/lib/payment_services/master_processing/response.rb b/lib/payment_services/master_processing/response.rb new file mode 100644 index 00000000..997435d7 --- /dev/null +++ b/lib/payment_services/master_processing/response.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class PaymentServices::MasterProcessing + class Response + include Virtus.model + + attribute :deposit_id, String + attribute :pay_invoice_url, String + attribute :source, Hash + + def self.build_from(raw_response:) + new( + deposit_id: extract_deposit_id(raw_response), + pay_invoice_url: extract_pay_invoice_url(raw_response), + source: raw_response + ) + end + + def success? + source['success'] + end + + def error_message + source['cause'] + end + + private + + def self.extract_deposit_id(raw_response) + raw_response['UID'] || raw_response['billID'] + end + + def self.extract_pay_invoice_url(raw_response) + raw_response['paymentURL'] || raw_response['paymentLinks']&.first + end + end +end diff --git a/lib/payment_services/merchant_alikassa.rb b/lib/payment_services/merchant_alikassa.rb new file mode 100644 index 00000000..abeba853 --- /dev/null +++ b/lib/payment_services/merchant_alikassa.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class MerchantAlikassa < Base + autoload :Invoicer, 'payment_services/merchant_alikassa/invoicer' + autoload :PayoutAdapter, 'payment_services/merchant_alikassa/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/merchant_alikassa/client.rb b/lib/payment_services/merchant_alikassa/client.rb new file mode 100644 index 00000000..2fdfba87 --- /dev/null +++ b/lib/payment_services/merchant_alikassa/client.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class PaymentServices::MerchantAlikassa + class Client < ::PaymentServices::Base::Client + API_URL = 'https://api-merchant.alikassa.com/v1' + PAYMENTS_PRIVATE_KEY_FILE_PATH = 'config/alikassa_payments_privatekey.pem' + PAYOUTS_PRIVATE_KEY_FILE_PATH = 'config/alikassa_payouts_privatekey.pem' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/payment", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params, PAYMENTS_PRIVATE_KEY_FILE_PATH)) + ) + end + + def invoice_transaction(deposit_id:) + params = { id: deposit_id } + safely_parse http_request( + url: "#{API_URL}/payment/status", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params, PAYMENTS_PRIVATE_KEY_FILE_PATH)) + ) + end + + def create_payout(params:) + safely_parse http_request( + url: "#{API_URL}/payout", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params, PAYOUTS_PRIVATE_KEY_FILE_PATH)) + ) + end + + def payout_transaction(payout_id:) + params = { id: payout_id } + safely_parse http_request( + url: "#{API_URL}/payout/status", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params, PAYOUTS_PRIVATE_KEY_FILE_PATH)) + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers(signature:) + { + 'Content-Type' => 'application/json', + 'Account' => "#{api_key}", + 'Sign' => signature + } + end + + def build_signature(params, private_key_file_path) + private_key = OpenSSL::PKey::read(File.read(private_key_file_path), secret_key) + signature = private_key.sign(OpenSSL::Digest::SHA1.new, params.to_json) + Base64.encode64(signature).gsub(/\n/, '') + end + end +end diff --git a/lib/payment_services/merchant_alikassa/invoice.rb b/lib/payment_services/merchant_alikassa/invoice.rb new file mode 100644 index 00000000..40b80899 --- /dev/null +++ b/lib/payment_services/merchant_alikassa/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::MerchantAlikassa + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'paid' + FAILED_PROVIDER_STATES = %w(cancel fail) + + self.table_name = 'merchant_alikassa_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state.in? FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/merchant_alikassa/invoicer.rb b/lib/payment_services/merchant_alikassa/invoicer.rb new file mode 100644 index 00000000..10a7eb13 --- /dev/null +++ b/lib/payment_services/merchant_alikassa/invoicer.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::MerchantAlikassa + class Invoicer < ::PaymentServices::Base::Invoicer + DEFAULT_USER_AGENT = 'Chrome/47.0.2526.111' + SBP_SERVICE = 'payment_card_sbp_rub_card' + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_invoice(params: invoice_params) + raise response['message'] if response['errors'] + + invoice.update!(deposit_id: response['id']) + PaymentServices::Base::Wallet.new( + address: response['cardNumber'], + name: response['cardHolderName'], + memo: response['bank'] + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.invoice_transaction(deposit_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction['payment_status']) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :income_payment_system, to: :order + delegate :currency, to: :income_payment_system + delegate :card_bank, :sbp_bank, :sbp?, to: :bank_resolver + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_params + { + amount: invoice.amount.to_i, + order_id: order.public_id.to_s, + service: sbp? ? SBP_SERVICE : "payment_card_number_#{currency.to_s.downcase}_card", + customer_code: sbp? ? sbp_bank : card_bank, + customer_user_id: "#{Rails.env}_user_id_#{order.user_id}", + customer_ip: order.remote_ip, + customer_browser_user_agent: DEFAULT_USER_AGENT, + success_redirect_url: order.success_redirect, + fail_redirect_url: order.failed_redirect + } + end + + def bank_resolver + @bank_resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/merchant_alikassa/payout.rb b/lib/payment_services/merchant_alikassa/payout.rb new file mode 100644 index 00000000..be61b5b7 --- /dev/null +++ b/lib/payment_services/merchant_alikassa/payout.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PaymentServices::MerchantAlikassa + class Payout < ::PaymentServices::Base::FiatPayout + self.table_name = 'merchant_alikassa_payouts' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == Invoice::SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state.in? Invoice::FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/merchant_alikassa/payout_adapter.rb b/lib/payment_services/merchant_alikassa/payout_adapter.rb new file mode 100644 index 00000000..f7f3baab --- /dev/null +++ b/lib/payment_services/merchant_alikassa/payout_adapter.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::MerchantAlikassa + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + Error = Class.new StandardError + P2P_RUB_SERVICE = 'payment_card_rub' + SBP_RUB_SERVICE = 'payment_card_sbp_rub' + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + transaction = client.payout_transaction(payout_id: payout.withdrawal_id) + payout.update_state_by_provider(transaction['payment_status']) if transaction + transaction + end + + private + + delegate :sbp_bank, :sbp?, to: :bank_resolver + + attr_reader :payout + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + response = client.create_payout(params: payout_params) + raise Error, response['message'] if response['errors'] + + payout.pay!(withdrawal_id: response['id']) + end + + def payout_params + order = OrderPayout.find(payout.order_payout_id).order + number = sbp? ? order.outcome_phone[1..-1] : payout.destination_account + + params = { + amount: "%.2f" % payout.amount.to_f, + number: number, + order_id: order.public_id.to_s, + service: service + } + params[:customer_code] = sbp_bank if sbp? + params + end + + def service + sbp? ? SBP_RUB_SERVICE : P2P_RUB_SERVICE + end + + def bank_resolver + @bank_resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def order + OrderPayout.find(payout.order_payout_id).order + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/merchant_alikassa_virtual.rb b/lib/payment_services/merchant_alikassa_virtual.rb new file mode 100644 index 00000000..93fee932 --- /dev/null +++ b/lib/payment_services/merchant_alikassa_virtual.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class MerchantAlikassaVirtual < Base + autoload :Invoicer, 'payment_services/merchant_alikassa_virtual/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/merchant_alikassa_virtual/client.rb b/lib/payment_services/merchant_alikassa_virtual/client.rb new file mode 100644 index 00000000..6fd9caef --- /dev/null +++ b/lib/payment_services/merchant_alikassa_virtual/client.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class PaymentServices::MerchantAlikassaVirtual + class Client < ::PaymentServices::Base::Client + API_URL = 'https://api-merchant.alikassa.com/v1' + PAYMENTS_PRIVATE_KEY_FILE_PATH = 'config/alikassa_payments_privatekey.pem' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/payment", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params, PAYMENTS_PRIVATE_KEY_FILE_PATH)) + ) + end + + def invoice_transaction(deposit_id:) + params = { id: deposit_id } + safely_parse http_request( + url: "#{API_URL}/payment/status", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params, PAYMENTS_PRIVATE_KEY_FILE_PATH)) + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers(signature:) + { + 'Content-Type' => 'application/json', + 'Account' => "#{api_key}", + 'Sign' => signature + } + end + + def build_signature(params, private_key_file_path) + private_key = OpenSSL::PKey::read(File.read(private_key_file_path), secret_key) + signature = private_key.sign(OpenSSL::Digest::SHA1.new, params.to_json) + Base64.encode64(signature).gsub(/\n/, '') + end + end +end diff --git a/lib/payment_services/merchant_alikassa_virtual/invoice.rb b/lib/payment_services/merchant_alikassa_virtual/invoice.rb new file mode 100644 index 00000000..344f6698 --- /dev/null +++ b/lib/payment_services/merchant_alikassa_virtual/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::MerchantAlikassaVirtual + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'paid' + FAILED_PROVIDER_STATES = %w(cancel fail) + + self.table_name = 'merchant_alikassa_virtual_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state.in? FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/merchant_alikassa_virtual/invoicer.rb b/lib/payment_services/merchant_alikassa_virtual/invoicer.rb new file mode 100644 index 00000000..0b420f19 --- /dev/null +++ b/lib/payment_services/merchant_alikassa_virtual/invoicer.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::MerchantAlikassaVirtual + class Invoicer < ::PaymentServices::Base::Invoicer + DEFAULT_USER_AGENT = 'Chrome/47.0.2526.111' + SERVICE = 'virtual_account_rub_hpp' + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_invoice(params: invoice_params) + raise response['message'] if response['errors'] + + invoice.update!( + deposit_id: response['id'], + pay_url: response['url'] + ) + end + + def pay_invoice_url + invoice.reload.pay_url if invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.invoice_transaction(deposit_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction['payment_status']) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def invoice_params + { + amount: invoice.amount.to_i, + order_id: order.public_id.to_s, + service: SERVICE, + customer_ip: order.remote_ip, + customer_user_id: "#{Rails.env}_user_id_#{order.user_id}" + } + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/obmenka.rb b/lib/payment_services/obmenka.rb new file mode 100644 index 00000000..969f4cff --- /dev/null +++ b/lib/payment_services/obmenka.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class Obmenka < Base + autoload :Invoicer, 'payment_services/obmenka/invoicer' + autoload :PayoutAdapter, 'payment_services/obmenka/payout_adapter' + register :payout_adapter, PayoutAdapter + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/obmenka/client.rb b/lib/payment_services/obmenka/client.rb new file mode 100644 index 00000000..b2f62e52 --- /dev/null +++ b/lib/payment_services/obmenka/client.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require 'digest' +require 'base64' + +class PaymentServices::Obmenka + class Client + include AutoLogger + TIMEOUT = 30 + API_URL = 'https://acquiring_api.obmenka.ua/api' + + def initialize(merchant_id:, secret_key:) + @merchant_id = merchant_id + @secret_key = secret_key + end + + def create_deposit(params:) + safely_parse http_request( + url: "#{API_URL}/einvoice/create", + method: :POST, + body: params + ) + end + + def process_payment_data(public_id:, deposit_id:) + safely_parse http_request( + url: "#{API_URL}/einvoice/process", + method: :POST, + body: { + payment_id: public_id, + tracking: deposit_id + } + ) + end + + def invoice_status(public_id:, deposit_id:) + safely_parse http_request( + url: "#{API_URL}/einvoice/status", + method: :POST, + body: { + payment_id: public_id, + tracking: deposit_id + } + ) + end + + def create_payout(params:) + safely_parse http_request( + url: "#{API_URL}/payment/create", + method: :POST, + body: params + ) + end + + def process_payout(public_id:, withdrawal_id:) + safely_parse http_request( + url: "#{API_URL}/payment/process", + method: :POST, + body: { + payment_id: public_id, + tracking: withdrawal_id + } + ) + end + + def payout_status(public_id:, withdrawal_id:) + safely_parse http_request( + url: "#{API_URL}/payment/status", + method: :POST, + body: { + payment_id: public_id, + tracking: withdrawal_id + } + ) + end + + private + + attr_reader :merchant_id, :secret_key + + def http_request(url:, method:, body: nil) + uri = URI.parse(url) + https = http(uri) + request = build_request(uri: uri, method: method, body: body) + logger.info "Request type: #{method} to #{uri} with payload #{request.body}" + https.request(request) + end + + def build_request(uri:, method:, body: nil) + request = if method == :POST + Net::HTTP::Post.new(uri.request_uri, headers(build_signature(body))) + elsif method == :GET + Net::HTTP::Get.new(uri.request_uri) + else + raise "Запрос #{method} не поддерживается!" + end + request.body = (body.present? ? body : {}).to_json + request + end + + def headers(signature) + { + 'Content-Type' => 'application/json', + 'DPAY_CLIENT' => merchant_id, + 'DPAY_SECURE' => signature + } + end + + def http(uri) + Net::HTTP.start(uri.host, uri.port, + use_ssl: true, + verify_mode: OpenSSL::SSL::VERIFY_NONE, + open_timeout: TIMEOUT, + read_timeout: TIMEOUT) + end + + def build_signature(request_body) + sign_string = ActiveSupport::JSON.encode(request_body) + sign_string = Digest::SHA1.digest(sign_string) + sign_string = Base64.strict_encode64(sign_string) + sign_string = secret_key + sign_string + secret_key + sign_string = Digest::MD5.digest(sign_string) + + Base64.strict_encode64(sign_string) + end + + def safely_parse(response) + res = JSON.parse(response.body) + logger.info "Response: #{res}" + res + rescue JSON::ParserError => err + logger.warn "Request failed #{response.class} #{response.body}" + Bugsnag.notify err do |report| + report.add_tab(:response, response_class: response.class, response_body: response.body) + end + response.body + end + end +end diff --git a/lib/payment_services/obmenka/invoice.rb b/lib/payment_services/obmenka/invoice.rb new file mode 100644 index 00000000..bd179613 --- /dev/null +++ b/lib/payment_services/obmenka/invoice.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class PaymentServices::Obmenka + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + self.table_name = 'obmenka_invoices' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + + validates :amount_cents, :order_public_id, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + event :cancel, transitions_to: :cancelled + end + + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount) + end + end + state :cancelled + end + + def update_state_by_provider(state) + update!(provider_state: state) + + pay! if success? + cancel! if failed? + end + + def order + Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + end + + private + + def success? + provider_state == 'FINISHED' + end + + def failed? + provider_state == 'FAILED' || provider_state == 'CANCELED' + end + end +end diff --git a/lib/payment_services/obmenka/invoicer.rb b/lib/payment_services/obmenka/invoicer.rb new file mode 100644 index 00000000..40b1f4d4 --- /dev/null +++ b/lib/payment_services/obmenka/invoicer.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Obmenka + class Invoicer < ::PaymentServices::Base::Invoicer + CARD_RU_SERVICE = 'visamaster.rur' + QIWI_SERVICE = 'qiwi' + + def create_invoice(money) + invoice = Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_deposit(params: build_invoice_params) + raise "Can't create invoice: #{response['error']['message']}" if response['error'] + invoice.update!(deposit_id: response['tracking']) + + response = client.process_payment_data(public_id: invoice.order_public_id, deposit_id: invoice.deposit_id) + raise "Can't get pay url: #{response['error']['message']}" if response['error'] + invoice.update!(pay_url: response['pay_link']) + end + + def pay_invoice_url + invoice.reload.pay_url if invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + response = client.invoice_status(public_id: invoice.order_public_id, deposit_id: invoice.deposit_id) + raise "Can't get invoice status: #{response['error']['message']}" if response['error'] + + invoice.update_state_by_provider(response['status']) if response['status'] + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def build_invoice_params + { + payment_id: order.public_id.to_s, + currency: payment_service_by_payway, + amount: invoice.amount.to_f, + description: "Payment for #{order.public_id}", + sender: order.income_account, + success_url: order.success_redirect, + fail_url: order.failed_redirect + } + end + + def payment_service_by_payway + available_options = { + 'visamc' => CARD_RU_SERVICE, + 'qiwi' => QIWI_SERVICE + } + available_options[order.income_wallet.payment_system.payway] + end + + def client + @client ||= begin + wallet = order.income_wallet + Client.new(merchant_id: wallet.merchant_id, secret_key: api_secret) + end + end + end +end diff --git a/lib/payment_services/obmenka/payout.rb b/lib/payment_services/obmenka/payout.rb new file mode 100644 index 00000000..47dfa7ed --- /dev/null +++ b/lib/payment_services/obmenka/payout.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class PaymentServices::Obmenka + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + self.table_name = 'obmenka_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(withdrawal_id:) + update(withdrawal_id: withdrawal_id) + end + + def update_state_by_provider(state) + update!(provider_state: state) + + confirm! if success? + fail! if status_failed? + end + + def public_id + "#{order_payout.order.public_id}-#{order_payout.id}" + end + + private + + def order_payout + @order_payout ||= OrderPayout.find(order_payout_id) + end + + def success? + provider_state == 'PAYED' + end + + def status_failed? + provider_state == 'CANCELED' || provider_state == 'FAILED' + end + end +end diff --git a/lib/payment_services/obmenka/payout_adapter.rb b/lib/payment_services/obmenka/payout_adapter.rb new file mode 100644 index 00000000..743611f5 --- /dev/null +++ b/lib/payment_services/obmenka/payout_adapter.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::Obmenka + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + CARD_RU_SERVICE = 'visamaster.rur' + QIWI_SERVICE = 'qiwi' + + Error = Class.new StandardError + PayoutStatusRequestFailed = Class.new Error + PayoutCreateRequestFailed = Class.new Error + PayoutProcessRequestFailed = Class.new Error + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + response = client.payout_status(public_id: payout.public_id, withdrawal_id: payout.withdrawal_id) + raise PayoutStatusRequestFailed, "Can't get payout status: #{response['error']['message']}" if response['error'] + + payout.update_state_by_provider(response['status']) if response['status'] + response + end + + private + + def make_payout(amount:, destination_account:, order_payout_id:) + payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + payout_params = { + recipient: destination_account, + currency: payment_service_by_payway, + amount: amount.to_f, + description: "Payout #{payout.public_id}", + payment_id: payout.public_id + } + response = client.create_payout(params: payout_params) + raise PayoutCreateRequestFailed, "Can't create payout: #{response['error']['message']}" if response['error'] + + payout.pay!(withdrawal_id: response['tracking']) + response = client.process_payout(public_id: payout.public_id, withdrawal_id: payout.withdrawal_id) + raise PayoutProcessRequestFailed, "Can't process payout: #{response['error']['message']}" if response['error'] + end + + def payment_service_by_payway + available_options = { + 'visamc' => CARD_RU_SERVICE, + 'qiwi' => QIWI_SERVICE + } + available_options[wallet.payment_system.payway] + end + + def client + @client ||= Client.new(merchant_id: wallet.merchant_id, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/oko_otc.rb b/lib/payment_services/oko_otc.rb new file mode 100644 index 00000000..9a5fb36e --- /dev/null +++ b/lib/payment_services/oko_otc.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class OkoOtc < Base + autoload :PayoutAdapter, 'payment_services/oko_otc/payout_adapter' + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/oko_otc/client.rb b/lib/payment_services/oko_otc/client.rb new file mode 100644 index 00000000..76232e45 --- /dev/null +++ b/lib/payment_services/oko_otc/client.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class PaymentServices::OkoOtc + class Client < ::PaymentServices::Base::Client + API_URL = 'https://oko-otc.ru/api/v2/payment' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def process_payout(params:) + safely_parse http_request( + url: "#{API_URL}/create_withdraw", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def payout_status(withdrawal_id:) + safely_parse http_request( + url: "#{API_URL}/fetch_order_by_uid/#{withdrawal_id}", + method: :GET, + headers: build_headers(signature: '') + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_signature(params) + sign_string = [params[:sum], params[:wallet], params[:orderUID], ''].join(';') + OpenSSL::HMAC.hexdigest('SHA512', secret_key, sign_string) + end + + def build_headers(signature:) + { + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => api_key, + 'Signature' => signature + } + end + end +end diff --git a/lib/payment_services/oko_otc/payout.rb b/lib/payment_services/oko_otc/payout.rb new file mode 100644 index 00000000..301bbf3f --- /dev/null +++ b/lib/payment_services/oko_otc/payout.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class PaymentServices::OkoOtc + class Payout < PaymentServices::ApplicationRecord + SUCCESS_PROVIDER_STATE = 'Выплачена' + FAILED_PROVIDER_STATE = 'Отмененная' + + include WorkflowActiverecord + + self.table_name = 'oko_otc_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, :order_payout_id, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(withdrawal_id:) + update(withdrawal_id: withdrawal_id) + end + + def update_state_by_provider(state) + update!(provider_state: state) + + confirm! if provider_succeed? + fail! if provider_failed? + end + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/oko_otc/payout_adapter.rb b/lib/payment_services/oko_otc/payout_adapter.rb new file mode 100644 index 00000000..6262807b --- /dev/null +++ b/lib/payment_services/oko_otc/payout_adapter.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::OkoOtc + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + order = OrderPayout.find(payout.order_payout_id).order + response = client.payout_status(withdrawal_id: "#{order.public_id}-#{payout.order_payout_id}") + + raise "Can't get withdraw history. Error Code: #{response['errCode']}" unless response['totalLen'] + + payout.update_state_by_provider(provider_state(response)) + response + end + + private + + def make_payout(amount:, destination_account:, order_payout_id:) + payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + order = OrderPayout.find(order_payout_id).order + + params = { + sum: amount.to_i, + currencyFrom: amount.currency.to_s, + wallet: destination_account, + bank: provider_bank, + orderUID: "#{order.public_id}-#{order_payout_id}" + } + curr = amount.currency.to_s.downcase.inquiry + params[:cardholder] = order.outcome_fio if curr.eur? || curr.usd? + params[:cardExpiration] = card_expiration(order) if curr.eur? || curr.azn? + if curr.usdt? + params[:sumInRub] = usdt_to_rub(amount: amount).to_i + params[:sum] = 1 + end + + response = client.process_payout(params: params) + raise "Can't create payout. Error Code: #{response['errCode']}" unless response['status'] + + payout.pay!(withdrawal_id: response['orderID']) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + + def card_expiration(order) + month, year = order.payment_card_exp_date.split('/') + year.length == 2 ? "#{month}/20#{year}" : order.payment_card_exp_date + end + + def provider_state(response) + response['data'].first.dig('orderStats', 'statusName') + end + + def provider_bank + @provider_bank ||= PaymentServices::Base::P2pBankResolver.new(adapter: self).card_bank + end + + def usdt_to_rub(amount:) + Gera::ExchangeRate.find_by(ps_from_id: 69, ps_to_id: 72).direction_rate.reverse_exchange(amount).to_i + end + end +end diff --git a/lib/payment_services/one_crypto.rb b/lib/payment_services/one_crypto.rb new file mode 100644 index 00000000..5eb80aff --- /dev/null +++ b/lib/payment_services/one_crypto.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class OneCrypto < Base + autoload :Invoicer, 'payment_services/one_crypto/invoicer' + autoload :PayoutAdapter, 'payment_services/one_crypto/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/one_crypto/client.rb b/lib/payment_services/one_crypto/client.rb new file mode 100644 index 00000000..44c49924 --- /dev/null +++ b/lib/payment_services/one_crypto/client.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class PaymentServices::OneCrypto + class Client < ::PaymentServices::Base::Client + API_URL = 'https://apiv2.expay.cash/api/transaction' + MERCHANT_ID = 'kassa.cc' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/create/in", + method: :POST, + body: params.merge(client_merchant_id: MERCHANT_ID).to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def create_payout(params:) + safely_parse http_request( + url: "#{API_URL}/create/out", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def transaction(tracker_id:) + params = { tracker_id: tracker_id } + safely_parse(http_request( + url: "#{API_URL}/get", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ))['transaction'] + end + + private + + attr_reader :api_key, :secret_key + + def build_headers(signature:) + { + 'Content-Type' => 'application/json', + 'ApiPublic' => api_key, + 'TimeStamp' => timestamp_string, + 'Signature' => signature + } + end + + def build_signature(params) + OpenSSL::HMAC.hexdigest('SHA512', secret_key, timestamp_string + params.to_json) + end + + def timestamp_string + @timestamp_string ||= Time.now.to_i.to_s + end + end +end diff --git a/lib/payment_services/one_crypto/invoice.rb b/lib/payment_services/one_crypto/invoice.rb new file mode 100644 index 00000000..ecc8995d --- /dev/null +++ b/lib/payment_services/one_crypto/invoice.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class PaymentServices::OneCrypto + class Invoice < ::PaymentServices::Base::CryptoInvoice + INITIAL_PROVIDER_STATE = 'ACCEPTED' + + self.table_name = 'one_crypto_invoices' + + monetize :amount_cents, as: :amount + + def update_state_by_transaction!(transaction) + validate_transaction_amount!(transaction: transaction) + + bind_transaction! if pending? + update!( + provider_state: transaction.status, + transaction_id: transaction.transaction_id + ) + pay!(payload: transaction) if transaction.succeed? + cancel! if transaction.failed? + end + + def transaction_created_at + nil + end + + private + + delegate :income_payment_system, to: :order + delegate :token_network, to: :income_payment_system + + def amount_provider_currency + @amount_provider_currency ||= PaymentServices::Paylama::CurrencyRepository.build_from(kassa_currency: amount_currency, token_network: token_network).provider_crypto_currency + end + + def validate_transaction_amount!(transaction:) + raise "#{amount.to_f} #{amount_provider_currency} is needed. But #{transaction.amount} #{transaction.currency} has come." unless transaction.valid_amount?(amount.to_f, amount_provider_currency) + end + end +end diff --git a/lib/payment_services/one_crypto/invoicer.rb b/lib/payment_services/one_crypto/invoicer.rb new file mode 100644 index 00000000..4df074de --- /dev/null +++ b/lib/payment_services/one_crypto/invoicer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' +require_relative 'transaction' + +class PaymentServices::OneCrypto + class Invoicer < ::PaymentServices::Base::Invoicer + def prepare_invoice_and_get_wallet!(currency:, token_network:) + invoice_params = { + token: PaymentServices::Paylama::CurrencyRepository.build_from(kassa_currency: currency, token_network: token_network).provider_crypto_currency, + client_transaction_id: order.id_in_unixtime.to_s, + call_back_url: order.income_payment_system.callback_url + } + + response = client.create_invoice(params: invoice_params) + raise "Can't create invoice: #{response['description']}" unless response['status'] == Invoice::INITIAL_PROVIDER_STATE + + PaymentServices::Base::Wallet.new(address: response['refer'], name: response['tracker_id']) + end + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + invoice.update!(deposit_id: order.income_wallet.name) + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + raw_transaction = client.transaction(tracker_id: invoice.deposit_id) + transaction = Transaction.build_from(raw_transaction) + invoice.update_state_by_transaction!(transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/one_crypto/payout.rb b/lib/payment_services/one_crypto/payout.rb new file mode 100644 index 00000000..bbec5292 --- /dev/null +++ b/lib/payment_services/one_crypto/payout.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class PaymentServices::OneCrypto + class Payout < ::PaymentServices::Base::CryptoPayout + self.table_name = 'one_crypto_payouts' + + monetize :amount_cents, as: :amount + + def txid + '' + end + end +end diff --git a/lib/payment_services/one_crypto/payout_adapter.rb b/lib/payment_services/one_crypto/payout_adapter.rb new file mode 100644 index 00000000..eb25af49 --- /dev/null +++ b/lib/payment_services/one_crypto/payout_adapter.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require_relative 'client' +require_relative 'payout' +require_relative 'transaction' + +class PaymentServices::OneCrypto + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + INITIAL_PROVIDER_STATE = 'ACCEPTED' + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + raw_transaction = client.transaction(tracker_id: payout.withdrawal_id) + transaction = Transaction.build_from(raw_transaction) + payout.update_state_by_provider!(transaction) + raw_transaction + end + + private + + attr_reader :payout + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + response = client.create_payout(params: payout_params) + raise "Can't create payout: #{response['description']}" unless response['status'] == INITIAL_PROVIDER_STATE + + payout.pay!(withdrawal_id: response['tracker_id']) + end + + def payout_params + { + token: currency, + amount: payout.amount.to_f, + client_transaction_id: "#{payout.order_payout_id}-#{payout.id}", + receiver: payout.destination_account + } + end + + def currency + PaymentServices::Paylama::CurrencyRepository.build_from(kassa_currency: wallet.currency, token_network: wallet.payment_system.token_network).provider_crypto_currency + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/one_crypto/transaction.rb b/lib/payment_services/one_crypto/transaction.rb new file mode 100644 index 00000000..72040b19 --- /dev/null +++ b/lib/payment_services/one_crypto/transaction.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class PaymentServices::OneCrypto + class Transaction + SUCCESS_PROVIDER_STATE = 'SUCCESS' + FAILED_PROVIDER_STATE = 'ERROR' + + include Virtus.model + + attribute :amount, Float + attribute :currency, String + attribute :status, String + attribute :transaction_id, String + attribute :fee, Float + attribute :source, Hash + + def self.build_from(raw_transaction) + new( + amount: raw_transaction['amount'].to_f, + currency: raw_transaction['token'], + status: raw_transaction['status'], + transaction_id: raw_transaction['hash'], + fee: raw_transaction['transaction_commission'].to_f, + source: raw_transaction + ) + end + + def to_s + source.to_s + end + + def valid_amount?(payout_amount, payout_currency) + (amount.zero? || amount == payout_amount) && currency == payout_currency + end + + def succeed? + status == SUCCESS_PROVIDER_STATE + end + + def failed? + status == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/panda_pay.rb b/lib/payment_services/panda_pay.rb new file mode 100644 index 00000000..540cc591 --- /dev/null +++ b/lib/payment_services/panda_pay.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class PandaPay < Base + autoload :Invoicer, 'payment_services/panda_pay/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/panda_pay/client.rb b/lib/payment_services/panda_pay/client.rb new file mode 100644 index 00000000..afdf8a7f --- /dev/null +++ b/lib/payment_services/panda_pay/client.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class PaymentServices::PandaPay + class Client < ::PaymentServices::Base::Client + API_URL = 'https://api.pandapay24.com' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + timestamp = Time.now.utc.to_i + params_as_json = params.to_json + request_body = "#{timestamp}#{params_as_json}" + + safely_parse http_request( + url: "#{API_URL}/orders", + method: :POST, + body: params_as_json, + headers: build_headers(signature: build_signature(request_body), timestamp: timestamp) + ) + end + + def invoice(deposit_id:) + timestamp = Time.now.utc.to_i + request_body = "#{timestamp}" + + safely_parse http_request( + url: "#{API_URL}/orders/#{deposit_id}", + method: :GET, + headers: build_headers(signature: build_signature(request_body), timestamp: timestamp) + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers(signature:, timestamp:) + { + 'X-API-Key' => api_key, + 'X-Signature' => signature, + 'X-Timestamp' => timestamp.to_s + } + end + + def build_signature(request_body) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), secret_key, request_body) + end + end +end diff --git a/lib/payment_services/panda_pay/invoice.rb b/lib/payment_services/panda_pay/invoice.rb new file mode 100644 index 00000000..322a3d2c --- /dev/null +++ b/lib/payment_services/panda_pay/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::PandaPay + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'completed' + FAILED_PROVIDER_STATES = %w(traderNotFound timeout canceled) + + self.table_name = 'panda_pay_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state.in? FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/panda_pay/invoicer.rb b/lib/payment_services/panda_pay/invoicer.rb new file mode 100644 index 00000000..97c62b58 --- /dev/null +++ b/lib/payment_services/panda_pay/invoicer.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::PandaPay + class Invoicer < ::PaymentServices::Base::Invoicer + CURRENCY_TO_COUNTRY = { + 'KZT' => 'KAZ', + 'AZN' => 'AZE', + 'TJS' => 'TJK' + } + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_invoice(params: invoice_params) + raise response['error'] if response['error'] + + invoice.update!(deposit_id: response['uuid']) + requisite_data = response['requisite_data'] + + PaymentServices::Base::Wallet.new( + address: requisite_data['requisites'], + name: requisite_data['owner_full_name'], + memo: requisite_data['bank_name_ru'] + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.invoice(deposit_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction['status']) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_params + { + amount_rub: invoice.amount.to_f.round(2), + countries: [CURRENCY_TO_COUNTRY[invoice.amount_currency.to_s]], + currency: invoice.amount_currency.to_s, + merchant_order_id: order.public_id.to_s, + requisite_type: 'card', + idempotency_key: SecureRandom.uuid + } + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/pay_for_u.rb b/lib/payment_services/pay_for_u.rb new file mode 100644 index 00000000..ca7ed781 --- /dev/null +++ b/lib/payment_services/pay_for_u.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class PayForU < Base + autoload :Invoicer, 'payment_services/pay_for_u/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/pay_for_u/client.rb b/lib/payment_services/pay_for_u/client.rb new file mode 100644 index 00000000..6e73f1b6 --- /dev/null +++ b/lib/payment_services/pay_for_u/client.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PaymentServices::PayForU + class Client < ::PaymentServices::Base::Client + API_URL = 'https://payforu.cash/public/api/v1' + + def initialize(api_key:) + @api_key = api_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/shop/orders", + method: :POST, + body: params.to_json, + headers: build_headers + ) + end + + def transaction(deposit_id:) + safely_parse http_request( + url: "#{API_URL}/shop/orders/#{deposit_id}", + method: :GET, + headers: build_headers + ) + end + + private + + attr_reader :api_key + + def build_headers + { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{api_key}" + } + end + end +end diff --git a/lib/payment_services/pay_for_u/invoice.rb b/lib/payment_services/pay_for_u/invoice.rb new file mode 100644 index 00000000..dd1590c7 --- /dev/null +++ b/lib/payment_services/pay_for_u/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::PayForU + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'completed' + FAILED_PROVIDER_STATE = 'error' + + self.table_name = 'pay_for_u_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/pay_for_u/invoicer.rb b/lib/payment_services/pay_for_u/invoicer.rb new file mode 100644 index 00000000..332ee2c4 --- /dev/null +++ b/lib/payment_services/pay_for_u/invoicer.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::PayForU + class Invoicer < ::PaymentServices::Base::Invoicer + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_invoice(params: invoice_params) + + invoice.update!( + deposit_id: response['id'], + pay_url: response.dig('integration', 'link') + ) + end + + def pay_invoice_url + invoice.present? ? URI.parse(invoice.reload.pay_url) : '' + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.transaction(deposit_id: invoice.deposit_id) + if transaction && amount_matched?(transaction) + invoice.update( + last_4_digits: transaction.dig('payment', 'customerCardLastDigits'), + payment_card_number: transaction.dig('requisites', 'cardInfo') + ) + invoice.update_state_by_provider(transaction['status']) + end + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :income_payment_system, to: :order + delegate :currency, to: :income_payment_system + + def invoice_params + { + amount: invoice.amount.to_i, + currency: currency.to_s, + customer: { + id: order.user_id.to_s, + email: order.user_email + }, + integration: { + externalOrderId: order.public_id.to_s, + returnUrl: order.success_redirect + } + } + end + + def amount_matched?(transaction) + transaction['amount'].to_i == invoice.amount.to_i + end + + def client + @client ||= Client.new(api_key: api_key) + end + end +end diff --git a/lib/payment_services/pay_for_u_h2h.rb b/lib/payment_services/pay_for_u_h2h.rb new file mode 100644 index 00000000..a823041a --- /dev/null +++ b/lib/payment_services/pay_for_u_h2h.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class PayForUH2h < Base + autoload :Invoicer, 'payment_services/pay_for_u_h2h/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/pay_for_u_h2h/client.rb b/lib/payment_services/pay_for_u_h2h/client.rb new file mode 100644 index 00000000..03cdc941 --- /dev/null +++ b/lib/payment_services/pay_for_u_h2h/client.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class PaymentServices::PayForUH2h + class Client < ::PaymentServices::PayForU::Client + def update_invoice(deposit_id:, params:) + safely_parse http_request( + url: "#{API_URL}/shop/orders/#{deposit_id}", + method: :PATCH, + body: params.to_json, + headers: build_headers + ) + end + + def start_payment(deposit_id:) + safely_parse http_request( + url: "#{API_URL}/shop/orders/#{deposit_id}/start-payment", + method: :POST, + body: {}.to_json, + headers: build_headers + ) + end + + def confirm_payment(deposit_id:) + safely_parse http_request( + url: "#{API_URL}/shop/orders/#{deposit_id}/confirm-payment", + method: :POST, + body: {}.to_json, + headers: build_headers + ) + end + end +end diff --git a/lib/payment_services/pay_for_u_h2h/invoice.rb b/lib/payment_services/pay_for_u_h2h/invoice.rb new file mode 100644 index 00000000..a5165640 --- /dev/null +++ b/lib/payment_services/pay_for_u_h2h/invoice.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class PaymentServices::PayForUH2h + class Invoice < ::PaymentServices::PayForU::Invoice + self.table_name = 'pay_for_u_h2h_invoices' + end +end diff --git a/lib/payment_services/pay_for_u_h2h/invoicer.rb b/lib/payment_services/pay_for_u_h2h/invoicer.rb new file mode 100644 index 00000000..27eafc1d --- /dev/null +++ b/lib/payment_services/pay_for_u_h2h/invoicer.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::PayForUH2h + class Invoicer < ::PaymentServices::Base::Invoicer + PAYMENT_TYPE = 'card2card' + PROVIDER_REQUISITES_FOUND_STATE = 'customer_confirm' + PROVIDER_REQUEST_RETRIES = 5 + Error = Class.new StandardError + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + update_provider_invoice_and_start_payment + card_number, card_holder = fetch_card_details! + + PaymentServices::Base::Wallet.new(address: card_number, name: card_holder) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.transaction(deposit_id: invoice.deposit_id) + if valid_transaction?(transaction) + invoice.update( + last_4_digits: transaction.dig('payment', 'customerCardLastDigits'), + payment_card_number: transaction.dig('requisites', 'cardInfo') + ) + invoice.update_state_by_provider(transaction['status']) + end + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + def confirm_payment + client.confirm_payment(deposit_id: invoice.deposit_id) + end + + private + + delegate :income_payment_system, :income_account, to: :order + delegate :currency, to: :income_payment_system + + def invoice_params + { + amount: invoice.amount.to_i, + currency: currency.to_s, + customer: { + id: order.user_id.to_s, + email: order.user_email + }, + integration: { + externalOrderId: order.public_id.to_s, + returnUrl: order.success_redirect + } + } + end + + def invoice_h2h_params + { + payment: { + bank: provider_bank, + type: PAYMENT_TYPE + } + } + end + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + deposit_id = client.create_invoice(params: invoice_params).dig('id') + invoice.update!(deposit_id: deposit_id) + end + + def update_provider_invoice_and_start_payment + update_provider_invoice(params: invoice_h2h_params) + client.start_payment(deposit_id: invoice.deposit_id) + end + + def update_provider_invoice(params:) + client.update_invoice(deposit_id: invoice.deposit_id, params: params) + end + + def fetch_card_details! + transaction = fetch_transaction + raise Error, 'Нет доступных реквизитов для оплаты' if transaction.is_a? Integer + + card_number, card_holder = transaction.dig('requisites', 'cardInfo'), transaction.dig('requisites', 'cardholder') + update_provider_invoice(params: { payment: { customerCardLastDigits: income_account.last(4) } }) + [card_number, card_holder] + end + + def fetch_transaction + PROVIDER_REQUEST_RETRIES.times do + sleep 3 + + transaction = client.transaction(deposit_id: invoice.deposit_id) + break transaction if transaction['status'] == PROVIDER_REQUISITES_FOUND_STATE + end + end + + def valid_transaction?(transaction) + transaction && transaction['amount'].to_i == invoice.amount.to_i + end + + def provider_bank + @provider_bank ||= PaymentServices::Base::P2pBankResolver.new(adapter: self).card_bank + end + + def client + @client ||= Client.new(api_key: api_key) + end + end +end diff --git a/lib/payment_services/paycraft.rb b/lib/payment_services/paycraft.rb new file mode 100644 index 00000000..d52c73a9 --- /dev/null +++ b/lib/payment_services/paycraft.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module PaymentServices + class Paycraft < Base + autoload :Invoicer, 'payment_services/paycraft/invoicer' + autoload :PayoutAdapter, 'payment_services/paycraft/payout_adapter' + + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/paycraft/client.rb b/lib/payment_services/paycraft/client.rb new file mode 100644 index 00000000..50f948b9 --- /dev/null +++ b/lib/payment_services/paycraft/client.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class PaymentServices::Paycraft + class Client < ::PaymentServices::Base::Client + API_URL = 'https://p2p-lk.paycraft.pro/api/proxy/19/transaction/service' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/create_pay_in", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def invoice(params:) + safely_parse http_request( + url: "#{API_URL}/payin_status", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def create_payout(params:) + safely_parse http_request( + url: "#{API_URL}/create_payout", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def payout(params:) + safely_parse http_request( + url: "#{API_URL}/payout_status", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers(signature:) + { + 'ApiPublic' => api_key, + 'Signature' => signature + } + end + + def build_signature(params) + OpenSSL::HMAC.hexdigest('SHA512', secret_key, params.to_json) + end + end +end diff --git a/lib/payment_services/paycraft/invoice.rb b/lib/payment_services/paycraft/invoice.rb new file mode 100644 index 00000000..79d3817c --- /dev/null +++ b/lib/payment_services/paycraft/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::Paycraft + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 2 + FAILED_PROVIDER_STATE = 3 + + self.table_name = 'paycraft_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/paycraft/invoicer.rb b/lib/payment_services/paycraft/invoicer.rb new file mode 100644 index 00000000..87e0be2d --- /dev/null +++ b/lib/payment_services/paycraft/invoicer.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Paycraft + class Invoicer < ::PaymentServices::Base::Invoicer + Error = Class.new StandardError + SBP_PAYWAY = 'СБП' + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_invoice(params: invoice_params) + raise Error, "Can't create invoice: #{response['description']}" if response['description'].present? + raise Error, "Can't create invoice: #{response['message']}" if response['message'].present? + + invoice.update!(deposit_id: order.public_id.to_s) + PaymentServices::Base::Wallet.new( + address: response['address'], + name: "#{response['surname']} #{response['first_name']}".presence, + memo: response['currency_name'].presence + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.invoice(params: { clientUniqueId: invoice.deposit_id }) + invoice.update(rate: transaction['course'].to_f) + invoice.update_state_by_provider(transaction['status']) if amount_valid?(transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :card_bank, :sbp?, to: :bank_resolver + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_params + { + external_id: order.public_id.to_s, + amount: invoice.amount.to_i, + token_name: sbp? ? SBP_PAYWAY : card_bank, + currency: invoice.amount_currency.to_s + } + end + + def amount_valid?(transaction) + transaction['amountPaid'] == transaction['amount'] || transaction['amountPaid'].zero? + end + + def bank_resolver + @bank_resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/paycraft/payout.rb b/lib/payment_services/paycraft/payout.rb new file mode 100644 index 00000000..301843de --- /dev/null +++ b/lib/payment_services/paycraft/payout.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::Paycraft + class Payout < ::PaymentServices::Base::FiatPayout + SUCCESS_PROVIDER_STATE = 2 + FAILED_PROVIDER_STATE = 3 + + self.table_name = 'paycraft_payouts' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/paycraft/payout_adapter.rb b/lib/payment_services/paycraft/payout_adapter.rb new file mode 100644 index 00000000..457c3283 --- /dev/null +++ b/lib/payment_services/paycraft/payout_adapter.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::Paycraft + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + Error = Class.new StandardError + SBP_PAYWAY = 'СБП' + CARD_PAYWAY = 'Межбанк' + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + transaction = client.payout(params: { clientUniqueId: payout.withdrawal_id }) + payout.update_state_by_provider(transaction['status']) + transaction + end + + private + + delegate :sbp?, :sbp_bank, to: :bank_resolver + + attr_reader :payout + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + response = client.create_payout(params: payout_params) + raise Error, "Can't create payout: #{response['reason']}" if response['reason'].present? + + payout.pay!(withdrawal_id: unique_id) + end + + def unique_id + @unique_id ||= "#{OrderPayout.find(payout.order_payout_id).order.public_id}-#{payout.order_payout_id}" + end + + def payout_params + { + clientUniqueId: unique_id, + destination: payout.destination_account, + amount: payout.amount.to_i, + walletId: sbp? ? SBP_PAYWAY : CARD_PAYWAY, + expiredTime: 30, + expiredOfferTime: 600, + sbp_bank: sbp? ? sbp_bank : '' + } + end + + def bank_resolver + @bank_resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/paycraft_virtual.rb b/lib/payment_services/paycraft_virtual.rb new file mode 100644 index 00000000..dc234bb0 --- /dev/null +++ b/lib/payment_services/paycraft_virtual.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module PaymentServices + class PaycraftVirtual < Base + autoload :Invoicer, 'payment_services/paycraft_virtual/invoicer' + + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/paycraft_virtual/client.rb b/lib/payment_services/paycraft_virtual/client.rb new file mode 100644 index 00000000..a2a0fbfa --- /dev/null +++ b/lib/payment_services/paycraft_virtual/client.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class PaymentServices::PaycraftVirtual + class Client < ::PaymentServices::Base::Client + API_URL = 'https://p2p-lk.paycraft.pro/api/proxy/19/transaction/service' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/create_pay_in", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def invoice(params:) + safely_parse http_request( + url: "#{API_URL}/payin_status", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers(signature:) + { + 'ApiPublic' => api_key, + 'Signature' => signature + } + end + + def build_signature(params) + OpenSSL::HMAC.hexdigest('SHA512', secret_key, params.to_json) + end + end +end diff --git a/lib/payment_services/paycraft_virtual/invoice.rb b/lib/payment_services/paycraft_virtual/invoice.rb new file mode 100644 index 00000000..64b0a5a9 --- /dev/null +++ b/lib/payment_services/paycraft_virtual/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::PaycraftVirtual + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 2 + FAILED_PROVIDER_STATE = 3 + + self.table_name = 'paycraft_virtual_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/paycraft_virtual/invoicer.rb b/lib/payment_services/paycraft_virtual/invoicer.rb new file mode 100644 index 00000000..0eea6fad --- /dev/null +++ b/lib/payment_services/paycraft_virtual/invoicer.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::PaycraftVirtual + class Invoicer < ::PaymentServices::Base::Invoicer + Error = Class.new StandardError + PAYWAY = 'Номер счета' + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_invoice(params: invoice_params) + raise Error, "Can't create invoice: #{response['description']}" if response['description'].present? + raise Error, "Can't create invoice: #{response['message']}" if response['message'].present? + + invoice.update!(deposit_id: order.public_id.to_s) + PaymentServices::Base::Wallet.new( + address: response['address'], + name: "#{response['surname']} #{response['first_name']}".presence, + memo: response['currency_name'].presence + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.invoice(params: { clientUniqueId: invoice.deposit_id }) + invoice.update(rate: transaction['course'].to_f) + invoice.update_state_by_provider(transaction['status']) if amount_valid?(transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_params + { + external_id: order.public_id.to_s, + amount: invoice.amount.to_i, + token_name: PAYWAY, + currency: invoice.amount_currency.to_s + } + end + + def amount_valid?(transaction) + transaction['amountPaid'] == transaction['amount'] || transaction['amountPaid'].zero? + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/payeer.rb b/lib/payment_services/payeer.rb index c199a253..d6b732bd 100644 --- a/lib/payment_services/payeer.rb +++ b/lib/payment_services/payeer.rb @@ -5,7 +5,9 @@ module PaymentServices class Payeer < Base autoload :Invoicer, 'payment_services/payeer/invoicer' + autoload :PayoutAdapter, 'payment_services/payeer/payout_adapter' register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter end end diff --git a/lib/payment_services/payeer/client.rb b/lib/payment_services/payeer/client.rb new file mode 100644 index 00000000..c4c7a239 --- /dev/null +++ b/lib/payment_services/payeer/client.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +class PaymentServices::Payeer + class Client < ::PaymentServices::Base::Client + API_URL = 'https://payeer.com/ajax/api/api.php' + + def initialize(api_id:, api_key:, currency:, account:, secret_key:) + @api_id = api_id + @api_key = api_key + @currency = currency + @account = account + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: API_URL + '?invoiceCreate', + method: :POST, + body: params.merge( + account: account, + apiId: secret_key, + apiPass: api_key, + action: 'invoiceCreate' + ), + headers: build_headers + ) + end + + def find_invoice(deposit_id:) + safely_parse http_request( + url: API_URL + '?paymentDetails', + method: :POST, + body: { + account: account, + apiId: secret_key, + apiPass: api_key, + action: 'paymentDetails', + merchantId: api_id, + referenceId: deposit_id + }, + headers: build_headers + ) + end + + def create_payout(params:) + safely_parse http_request( + url: API_URL + '?transfer', + method: :POST, + body: params.merge( + apiId: secret_key, + apiPass: api_key, + curIn: currency, + curOut: currency, + action: 'transfer' + ), + headers: build_headers + ) + end + + def payments(params:) + safely_parse http_request( + url: API_URL + '?history', + method: :POST, + body: params.merge( + apiId: secret_key, + apiPass: api_key, + action: 'history' + ), + headers: build_headers + ) + end + + private + + attr_reader :api_id, :api_key, :currency, :account, :secret_key + + def build_request(uri:, method:, body: nil, headers:) + request = if method == :POST + Net::HTTP::Post.new(uri.request_uri, headers) + elsif method == :GET + Net::HTTP::Get.new(uri.request_uri, headers) + else + raise "Запрос #{method} не поддерживается!" + end + request.body = URI.encode_www_form((body.present? ? body : {})) + request + end + + def build_headers + { + 'content_type' => 'application/x-www-form-urlencoded' + } + end + end +end diff --git a/lib/payment_services/payeer/invoice.rb b/lib/payment_services/payeer/invoice.rb index d04271e1..805de6da 100644 --- a/lib/payment_services/payeer/invoice.rb +++ b/lib/payment_services/payeer/invoice.rb @@ -3,47 +3,16 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex class PaymentServices::Payeer - class Invoice < ApplicationRecord - include Workflow + class Invoice < ::PaymentServices::Base::FiatInvoice self.table_name = 'payeer_invoices' - scope :ordered, -> { order(id: :desc) } - monetize :amount_cents, as: :amount - validates :amount_cents, :order_public_id, :state, presence: true - - workflow_column :state - workflow do - state :pending do - event :pay, transitions_to: :paid - event :cancel, transitions_to: :cancelled - end - - state :paid do - on_entry do - preliminary_order&.auto_confirm!(income_amount: amount) - end - end - state :cancelled - end - - def can_be_confirmed?(income_money:) - pending? && amount == income_money - end - - def pay(payload:) - update(payload: payload) - end - - def order - Order.find_by(public_id: order_public_id) - end - - private - - def preliminary_order - PreliminaryOrder.find_by(public_id: order_public_id) + def update_state_by_provider(invoice_transactions) + invoice_transactions_sum = invoice_transactions.sum do |transaction| + transaction['currency'] == amount_currency ? transaction['amount'] : 0 + end + pay! if invoice_transactions_sum == amount.to_f end end end diff --git a/lib/payment_services/payeer/invoicer.rb b/lib/payment_services/payeer/invoicer.rb index 02f9ec50..ca02ffa3 100644 --- a/lib/payment_services/payeer/invoicer.rb +++ b/lib/payment_services/payeer/invoicer.rb @@ -3,44 +3,51 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex require_relative 'invoice' +require_relative 'client' class PaymentServices::Payeer class Invoicer < ::PaymentServices::Base::Invoicer - PAYEER_URL = 'https://payeer.com/merchant/' - def create_invoice(money) Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_invoice(params: invoice_params) + + invoice.update!( + deposit_id: order.public_id.to_s, + pay_url: response['url'] + ) end def pay_invoice_url - invoice = Invoice.find_by!(order_public_id: order.public_id) + invoice.present? ? URI.parse(invoice.reload.pay_url) : '' + end - payment_data = { - amount: format('%.2f', invoice.amount.to_f), - currency: invoice.amount.currency.to_s, - description: Base64.strict_encode64(I18n.t('payment_systems.default_product', order_id: order.public_id)) - } + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.find_invoice(deposit_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction['items']) + end - sign_array = [ - order.income_wallet.merchant_id, - order.public_id, - payment_data[:amount], - payment_data[:currency], - payment_data[:description], - order.income_wallet.api_key - ] - signature = Digest::SHA256.hexdigest(sign_array.join(':')).upcase - - uri = URI.parse(PAYEER_URL) - uri.query = { + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def client + @client ||= Client.new(api_id: order.income_wallet.merchant_id, api_key: api_key, currency: order.income_wallet.currency.to_s, account: order.income_wallet.num_ps, secret_key: api_secret) + end + + def invoice_params + { m_shop: order.income_wallet.merchant_id, - m_orderid: order.public_id, - m_amount: payment_data[:amount], - m_curr: payment_data[:currency], - m_desc: payment_data[:description], - m_sign: signature - }.to_query - uri + m_orderid: order.public_id.to_s, + m_amount: invoice.amount.to_d, + m_curr: invoice.amount_currency.to_s, + m_desc: "##{order.public_id}" + } end end end diff --git a/lib/payment_services/payeer/payout.rb b/lib/payment_services/payeer/payout.rb new file mode 100644 index 00000000..2c80d201 --- /dev/null +++ b/lib/payment_services/payeer/payout.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class PaymentServices::Payeer + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + self.table_name = 'payeer_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay + update(reference_id: build_reference_id) + end + + def update_provider_state(provider_state) + update!(provider_state: provider_state) + + confirm! if success? + fail! if failed? + end + + def order_payout + @order_payout ||= OrderPayout.find(order_payout_id) + end + + def build_reference_id + "#{order_payout.order.public_id}-#{order_payout.id}" + end + + private + + def success? + provider_state == 'success' + end + + def failed? + provider_state == 'canceled' + end + end +end diff --git a/lib/payment_services/payeer/payout_adapter.rb b/lib/payment_services/payeer/payout_adapter.rb new file mode 100644 index 00000000..367c9c80 --- /dev/null +++ b/lib/payment_services/payeer/payout_adapter.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::Payeer + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + response = client.payments(params: { account: wallet.num_ps }) + + raise "Can't get withdrawal details: #{response['errors']}" if response['errors'].any? + + payment = response['history'].values.find do |payment| + payment['referenceId'] == payout.reference_id + end + + payout.update_provider_state(payment['status']) if payment + + payment + end + + private + + def make_payout(amount:, destination_account:, order_payout_id:) + payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + + params = { + account: wallet.num_ps, + sumOut: amount.to_d, + to: destination_account, + comment: "Перевод по заявке №#{payout.order_payout.order.public_id} на сайте Kassa.cc", + referenceId: payout.build_reference_id + } + response = client.create_payout(params: params) + + raise "Can't process payout: #{response['errors']}" if response['errors'].is_a? Array + + payout.pay! + end + + def client + @client ||= Client.new(api_id: wallet.merchant_id, api_key: api_key, currency: wallet.currency.to_s, account: wallet.num_ps, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/paylama.rb b/lib/payment_services/paylama.rb new file mode 100644 index 00000000..cf9fb59d --- /dev/null +++ b/lib/payment_services/paylama.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class Paylama < Base + autoload :Invoicer, 'payment_services/paylama/invoicer' + autoload :PayoutAdapter, 'payment_services/paylama/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/paylama/client.rb b/lib/payment_services/paylama/client.rb new file mode 100644 index 00000000..07dc8cae --- /dev/null +++ b/lib/payment_services/paylama/client.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +class PaymentServices::Paylama + class Client < ::PaymentServices::Base::Client + FIAT_API_URL = 'https://admin.paylama.io/api/api/payment' + CRYPTO_API_URL = 'https://admin.paylama.io/api/crypto' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_fiat_invoice(params:) + safely_parse http_request( + url: "#{FIAT_API_URL}/generate_invoice_h2h", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def process_fiat_payout(params:) + safely_parse http_request( + url: "#{FIAT_API_URL}/generate_withdraw", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def create_p2p_invoice(params:) + safely_parse http_request( + url: "#{FIAT_API_URL}/generate_invoice_card_transfer", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def create_crypto_address(currency:) + params = { currency: currency } + safely_parse http_request( + url: "#{CRYPTO_API_URL}/payment", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def process_crypto_payout(params:) + safely_parse http_request( + url: "#{CRYPTO_API_URL}/payout", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + def payment_status(payment_id:, type:) + params = { + externalID: payment_id, + orderType: type + } + + safely_parse http_request( + url: "#{FIAT_API_URL}/get_order_details", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_signature(params) + OpenSSL::HMAC.hexdigest('SHA512', secret_key, params.to_json) + end + + def build_headers(signature:) + { + 'Content-Type' => 'application/json', + 'API-Key' => api_key, + 'Signature' => signature + } + end + end +end diff --git a/lib/payment_services/paylama/currency_repository.rb b/lib/payment_services/paylama/currency_repository.rb new file mode 100644 index 00000000..83efdb20 --- /dev/null +++ b/lib/payment_services/paylama/currency_repository.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class PaymentServices::Paylama + class CurrencyRepository + CURRENCY_TO_PROVIDER_CURRENCY = { RUB: 1, USD: 2, KZT: 3, EUR: 4, UZS: 36, AZN: 37, DSH: 'DASH' }.stringify_keys.freeze + TOKEN_NETWORK_TO_PROVIDER_CURRENCY = { erc20: 'USDT', trc20: 'USDTTRC', bep20: 'USDTBEP', bep2: 'BNB' }.stringify_keys.freeze + TOKEN_NETWORK_TO_GETBLOCK_CURRENCY = { erc20: 'USDTERC20', trc20: 'USDTTRC20' }.stringify_keys.freeze + BNB_BEP20_PROVIDER_CURRENCY = 'BNB20' + BNB_BEP20_TOKEN_NETWORK = 'bep20' + + include Virtus.model + + attribute :kassa_currency, String + attribute :token_network, String + + def self.build_from(kassa_currency:, token_network: nil) + new( + kassa_currency: kassa_currency.to_s, + token_network: token_network + ) + end + + def fiat_currency_id + CURRENCY_TO_PROVIDER_CURRENCY[kassa_currency] + end + + def provider_crypto_currency + return BNB_BEP20_PROVIDER_CURRENCY if bnb_bep20? + return TOKEN_NETWORK_TO_PROVIDER_CURRENCY[token_network] if token_network.present? + + CURRENCY_TO_PROVIDER_CURRENCY[kassa_currency] || kassa_currency + end + + def getblock_currency + return TOKEN_NETWORK_TO_GETBLOCK_CURRENCY[token_network] if token_network.present? + + CURRENCY_TO_PROVIDER_CURRENCY[kassa_currency] || kassa_currency + end + + private + + def bnb_bep20? + kassa_currency.inquiry.BNB? && token_network == BNB_BEP20_TOKEN_NETWORK + end + end +end diff --git a/lib/payment_services/paylama/invoice.rb b/lib/payment_services/paylama/invoice.rb new file mode 100644 index 00000000..6ad233aa --- /dev/null +++ b/lib/payment_services/paylama/invoice.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class PaymentServices::Paylama + class Invoice < PaymentServices::ApplicationRecord + SUCCESS_PROVIDER_STATE = 'Succeed' + FAILED_PROVIDER_STATE = 'Failed' + + include WorkflowActiverecord + + self.table_name = 'paylama_invoices' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + + validates :amount_cents, :order_public_id, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + event :cancel, transitions_to: :cancelled + end + + state :paid do + on_entry do + order.auto_confirm!(income_amount: amount, hash: deposit_id) + end + end + state :cancelled + end + + def update_state_by_provider(state) + update!(provider_state: state) + + pay! if provider_succeed? + cancel! if provider_failed? + end + + def order + Order.find_by(public_id: order_public_id) || PreliminaryOrder.find_by(public_id: order_public_id) + end + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/paylama/invoicer.rb b/lib/payment_services/paylama/invoicer.rb new file mode 100644 index 00000000..26d9d91c --- /dev/null +++ b/lib/payment_services/paylama/invoicer.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' +require_relative 'currency_repository' + +class PaymentServices::Paylama + class Invoicer < ::PaymentServices::Base::Invoicer + P2P_BANK_NAME = 'tinkoff' + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + response = client.create_p2p_invoice(params: invoice_p2p_params) + PaymentServices::Base::Wallet.new(address: response['cardNumber'], name: response['cardHolderName']) + end + + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id) + response = client.create_fiat_invoice(params: invoice_params) + raise "Can't create invoice: #{response['cause']}" unless response['success'] + + invoice.update!( + deposit_id: response['billID'], + pay_url: response['paymentURL'] + ) + end + + def pay_invoice_url + (invoice.present? && invoice.reload.pay_url.present?) ? URI.parse(invoice.pay_url) : '' + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + response = client.payment_status(payment_id: invoice.deposit_id, type: 'invoice') + raise 'Empty paylama response' unless response&.dig('ID') + + invoice.update_state_by_provider(response['status']) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def invoice_params + { + amount: invoice.amount.to_i, + expireAt: order.income_payment_timeout, + comment: order.public_id.to_s, + clientIP: order.remote_ip || '', + currencyID: invoice_fiat_currency_id, + redirect: { + successURL: order.success_redirect, + failURL: order.failed_redirect + } + } + end + + def invoice_p2p_params + { + bankName: P2P_BANK_NAME, + amount: order.income_money.to_i, + comment: order.public_id.to_s, + currencyID: invoice_fiat_currency_id + } + end + + def invoice_fiat_currency_id + @invoice_fiat_currency_id ||= CurrencyRepository.build_from(kassa_currency: order.income_payment_system.currency).fiat_currency_id + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/paylama/payout.rb b/lib/payment_services/paylama/payout.rb new file mode 100644 index 00000000..973e4eb8 --- /dev/null +++ b/lib/payment_services/paylama/payout.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class PaymentServices::Paylama + class Payout < PaymentServices::ApplicationRecord + SUCCESS_PROVIDER_STATE = 'Succeed' + FAILED_PROVIDER_STATE = 'Failed' + + include WorkflowActiverecord + + self.table_name = 'paylama_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, :order_payout_id, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(withdrawal_id:) + update(withdrawal_id: withdrawal_id) + end + + def update_state_by_provider(state) + update!(provider_state: state) + + confirm! if provider_succeed? + fail! if provider_failed? + end + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/paylama/payout_adapter.rb b/lib/payment_services/paylama/payout_adapter.rb new file mode 100644 index 00000000..466a4b68 --- /dev/null +++ b/lib/payment_services/paylama/payout_adapter.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' +require_relative 'currency_repository' + +class PaymentServices::Paylama + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + PAYOUT_TIME_ALIVE = 1800.seconds + PAYSOURCE_OPTIONS = { + 'visamc' => 'card', + 'cardh2h' => 'card', + 'qiwi' => 'qw', + 'qiwih2h' => 'qw' + } + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + response = client.payment_status(payment_id: payout.withdrawal_id, type: 'withdraw') + raise "Can't get payment information: #{response}" unless response['ID'] + + payout.update_state_by_provider(response['status']) + response + end + + private + + attr_reader :payout + delegate :outcome_api_key, :outcome_api_secret, to: :wallet + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + response = client.process_fiat_payout(params: payout_params) + raise "Can't create payout: #{response}" unless response['success'] + + payout.pay!(withdrawal_id: response['billID']) + end + + def payout_params + order = OrderPayout.find(payout.order_payout_id).order + { + amount: payout.amount.to_i, + expireAt: PAYOUT_TIME_ALIVE.to_i, + comment: "#{order.public_id}-#{payout.order_payout_id}", + clientIP: order.remote_ip || '', + paySourcesFilter: pay_source, + currencyID: CurrencyRepository.build_from(kassa_currency: wallet.currency).fiat_currency_id, + recipient: payout.destination_account + } + end + + def pay_source + PAYSOURCE_OPTIONS[wallet.payment_system.payway] + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/paylama_crypto.rb b/lib/payment_services/paylama_crypto.rb new file mode 100644 index 00000000..63dba23c --- /dev/null +++ b/lib/payment_services/paylama_crypto.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module PaymentServices + class PaylamaCrypto < Base + autoload :Invoicer, 'payment_services/paylama_crypto/invoicer' + autoload :PayoutAdapter, 'payment_services/paylama_crypto/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + + def self.payout_contains_fee? + true + end + end +end diff --git a/lib/payment_services/paylama_crypto/invoice.rb b/lib/payment_services/paylama_crypto/invoice.rb new file mode 100644 index 00000000..2b30e3dd --- /dev/null +++ b/lib/payment_services/paylama_crypto/invoice.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PaymentServices::PaylamaCrypto + class Invoice < ::PaymentServices::Base::CryptoInvoice + self.table_name = 'paylama_invoices' + + monetize :amount_cents, as: :amount + + def update_state_by_transaction(transaction) + validate_transaction_amount(transaction: transaction) + bind_transaction! if pending? + update!( + provider_state: transaction.status, + transaction_created_at: transaction.created_at, + fee: transaction.fee + ) + + pay!(payload: transaction) if transaction.succeed? + cancel! if transaction.failed? + end + + def transaction_id + order.income_wallet.name + end + + private + + delegate :income_payment_system, to: :order + delegate :token_network, to: :income_payment_system + + def validate_transaction_amount(transaction:) + raise "#{amount.to_f} #{amount_provider_currency} is needed. But #{transaction.amount} #{transaction.currency} has come." unless transaction.valid_amount?(amount.to_f, amount_provider_currency) + end + + def amount_provider_currency + @amount_provider_currency ||= PaymentServices::Paylama::CurrencyRepository.build_from(kassa_currency: amount_currency, token_network: token_network).provider_crypto_currency + end + end +end diff --git a/lib/payment_services/paylama_crypto/invoicer.rb b/lib/payment_services/paylama_crypto/invoicer.rb new file mode 100644 index 00000000..b4ce53b6 --- /dev/null +++ b/lib/payment_services/paylama_crypto/invoicer.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'transaction' + +class PaymentServices::PaylamaCrypto + class Invoicer < ::PaymentServices::Base::Invoicer + def create_invoice(money) + Invoice.create(amount: money, order_public_id: order.public_id, address: order.income_account_emoney) + end + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + provider_crypto_currency = PaymentServices::Paylama::CurrencyRepository.build_from(kassa_currency: currency, token_network: token_network).provider_crypto_currency + response = client.create_crypto_address(currency: provider_crypto_currency) + raise "Can't create crypto address: #{response['cause']}" unless response['id'] + + PaymentServices::Base::Wallet.new(address: response['address'], name: response['id']) + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + raw_transaction = client.payment_status(payment_id: order.income_wallet.name, type: 'invoice') + raise "Can't get payment information: #{raw_transaction['cause']}" unless raw_transaction['ID'] + + transaction = Transaction.build_from(raw_transaction) + invoice.update_state_by_transaction(transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def client + @client ||= PaymentServices::Paylama::Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/paylama_crypto/payout.rb b/lib/payment_services/paylama_crypto/payout.rb new file mode 100644 index 00000000..db7ae5ac --- /dev/null +++ b/lib/payment_services/paylama_crypto/payout.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class PaymentServices::PaylamaCrypto + class Payout < ::PaymentServices::Paylama::Payout + def update_state_by_transaction(transaction) + update!( + provider_state: transaction.status, + fee: transaction.fee + ) + + confirm! if transaction.succeed? + fail! if transaction.failed? + end + end +end diff --git a/lib/payment_services/paylama_crypto/payout_adapter.rb b/lib/payment_services/paylama_crypto/payout_adapter.rb new file mode 100644 index 00000000..e38175c7 --- /dev/null +++ b/lib/payment_services/paylama_crypto/payout_adapter.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'transaction' + +class PaymentServices::PaylamaCrypto + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + raw_transaction = client.payment_status(payment_id: payout.withdrawal_id, type: 'withdraw') + raise "Can't get payment information: #{raw_transaction}" unless raw_transaction['ID'] + + transaction = Transaction.build_from(raw_transaction) + payout.update_state_by_transaction(transaction) + raw_transaction + end + + private + + attr_reader :payout + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + response = client.process_crypto_payout(params: payout_params) + raise "Can't create payout: #{response}" unless response['ID'] + + payout.pay!(withdrawal_id: response['ID']) + end + + def payout_params + { + amount: payout.amount.to_f, + currency: currency, + address: payout.destination_account + } + end + + def currency + @currency ||= PaymentServices::Paylama::CurrencyRepository.build_from(kassa_currency: wallet.currency, token_network: wallet.payment_system.token_network).provider_crypto_currency + end + + def client + @client ||= PaymentServices::Paylama::Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/paylama_crypto/transaction.rb b/lib/payment_services/paylama_crypto/transaction.rb new file mode 100644 index 00000000..801ff02c --- /dev/null +++ b/lib/payment_services/paylama_crypto/transaction.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class PaymentServices::PaylamaCrypto + class Transaction + SUCCESS_TRANSACTION_STATE = 'Succeed' + FAILED_TRANSACTION_STATE = 'Failed' + + include Virtus.model + + attribute :amount, Float + attribute :currency, String + attribute :status, String + attribute :fee, Float + attribute :created_at, DateTime + attribute :source, Hash + + def self.build_from(raw_transaction) + new( + amount: raw_transaction['amount'].to_f, + currency: raw_transaction['currency'], + status: raw_transaction['status'], + fee: raw_transaction['fee'], + created_at: DateTime.strptime(raw_transaction['createdAt'].to_s,'%s').utc, + source: raw_transaction + ) + end + + def to_s + source.to_s + end + + def valid_amount?(payout_amount, payout_currency) + (amount == 0 || amount == payout_amount) && currency == payout_currency + end + + def succeed? + send("#{currency}_transaction_succeed?") + end + + def failed? + status == FAILED_TRANSACTION_STATE + end + + private + + def method_missing(method_name) + super unless method_name.end_with?('_transaction_succeed?') + + default_transaction_succeed? + end + + def default_transaction_succeed? + status == SUCCESS_TRANSACTION_STATE + end + end +end diff --git a/lib/payment_services/paylama_p2p.rb b/lib/payment_services/paylama_p2p.rb new file mode 100644 index 00000000..98aea78d --- /dev/null +++ b/lib/payment_services/paylama_p2p.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class PaylamaP2p < Base + autoload :Invoicer, 'payment_services/paylama_p2p/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/paylama_p2p/client.rb b/lib/payment_services/paylama_p2p/client.rb new file mode 100644 index 00000000..f4262826 --- /dev/null +++ b/lib/payment_services/paylama_p2p/client.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class PaymentServices::PaylamaP2p + class Client < ::PaymentServices::Paylama::Client + def create_provider_invoice(params:) + safely_parse http_request( + url: "#{FIAT_API_URL}/generate_invoice_card_transfer", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + end +end diff --git a/lib/payment_services/paylama_p2p/invoice.rb b/lib/payment_services/paylama_p2p/invoice.rb new file mode 100644 index 00000000..55f91e08 --- /dev/null +++ b/lib/payment_services/paylama_p2p/invoice.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class PaymentServices::PaylamaP2p + class Invoice < ::PaymentServices::PaylamaSbp::Invoice + self.table_name = 'paylama_p2p_invoices' + end +end diff --git a/lib/payment_services/paylama_p2p/invoicer.rb b/lib/payment_services/paylama_p2p/invoicer.rb new file mode 100644 index 00000000..6fb58785 --- /dev/null +++ b/lib/payment_services/paylama_p2p/invoicer.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::PaylamaP2p + class Invoicer < ::PaymentServices::Base::Invoicer + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_provider_invoice(params: invoice_p2p_params) + raise response['cause'] unless response['success'] + + invoice.update!(deposit_id: response['externalId']) + PaymentServices::Base::Wallet.new( + address: response['cardNumber'], + name: response['cardHolderName'], + memo: PROVIDER_BANK_NAME.capitalize + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.payment_status(payment_id: invoice.deposit_id, type: 'invoice') + invoice.update_state_by_provider(transaction['status']) if valid_transaction?(transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :income_payment_system, to: :order + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_p2p_params + { + clientOrderID: order.public_id.to_s, + payerID: "#{Rails.env}_user_id_#{order.user_id}", + amount: invoice.amount.to_i, + bankName: provider_bank, + comment: "Order #{order.public_id}", + currencyID: currency_id, + expireAt: order.income_payment_timeout + } + end + + def valid_transaction?(transaction) + transaction && transaction['amount'].to_i == invoice.amount.to_i + end + + def provider_bank + @provider_bank ||= PaymentServices::Base::P2pBankResolver.new(adapter: self).card_bank + end + + def currency_id + @currency_id ||= Paylama::CurrencyRepository.build_from(kassa_currency: order.income_payment_system.currency).fiat_currency_id + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/paylama_sbp.rb b/lib/payment_services/paylama_sbp.rb new file mode 100644 index 00000000..7f704f66 --- /dev/null +++ b/lib/payment_services/paylama_sbp.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class PaylamaSbp < Base + autoload :Invoicer, 'payment_services/paylama_sbp/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/paylama_sbp/client.rb b/lib/payment_services/paylama_sbp/client.rb new file mode 100644 index 00000000..8457e040 --- /dev/null +++ b/lib/payment_services/paylama_sbp/client.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class PaymentServices::PaylamaSbp + class Client < ::PaymentServices::Paylama::Client + def create_provider_invoice(params:) + safely_parse http_request( + url: "#{FIAT_API_URL}/generate_invoice_fps_h2h", + method: :POST, + body: params.to_json, + headers: build_headers(signature: build_signature(params)) + ) + end + end +end diff --git a/lib/payment_services/paylama_sbp/invoice.rb b/lib/payment_services/paylama_sbp/invoice.rb new file mode 100644 index 00000000..5cc84a37 --- /dev/null +++ b/lib/payment_services/paylama_sbp/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::PaylamaSbp + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'Succeed' + FAILED_PROVIDER_STATE = 'Failed' + + self.table_name = 'paylama_sbp_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/paylama_sbp/invoicer.rb b/lib/payment_services/paylama_sbp/invoicer.rb new file mode 100644 index 00000000..a2675e0d --- /dev/null +++ b/lib/payment_services/paylama_sbp/invoicer.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::PaylamaSbp + class Invoicer < ::PaymentServices::Base::Invoicer + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_provider_invoice(params: invoice_fps_params) + raise response['cause'] unless response['success'] + + invoice.update!(deposit_id: response['externalID']) + PaymentServices::Base::Wallet.new( + address: prepare_phone_number(response['phoneNumber']), + name: response['cardHolderName'], + memo: response['bankName'].capitalize + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.payment_status(payment_id: invoice.deposit_id, type: 'invoice') + invoice.update_state_by_provider(transaction['status']) if valid_transaction?(transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :income_payment_system, to: :order + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_fps_params + { + payerID: "#{Rails.env}_user_id_#{order.user_id}", + currencyID: currency_id, + expireAt: order.income_payment_timeout, + amount: invoice.amount.to_i, + clientOrderID: order.public_id.to_s + } + end + + def currency_id + PaymentServices::Paylama::CurrencyRepository.build_from(kassa_currency: income_payment_system.currency).fiat_currency_id + end + + def prepare_phone_number(provider_phone_number) + "+#{provider_phone_number[0]} (#{provider_phone_number[1..3]}) #{provider_phone_number[4..6]}-#{provider_phone_number[7..8]}-#{provider_phone_number[9..10]}" + end + + def valid_transaction?(transaction) + transaction && transaction['amount'].to_i == invoice.amount.to_i + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/perfect_money.rb b/lib/payment_services/perfect_money.rb index 43fab9be..5b3a8997 100644 --- a/lib/payment_services/perfect_money.rb +++ b/lib/payment_services/perfect_money.rb @@ -5,7 +5,9 @@ module PaymentServices class PerfectMoney < Base autoload :Invoicer, 'payment_services/perfect_money/invoicer' + autoload :PayoutAdapter, 'payment_services/perfect_money/payout_adapter' register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter end end diff --git a/lib/payment_services/perfect_money/client.rb b/lib/payment_services/perfect_money/client.rb new file mode 100644 index 00000000..a2fbd0f8 --- /dev/null +++ b/lib/payment_services/perfect_money/client.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require 'nokogiri' +require 'csv' + +class PaymentServices::PerfectMoney + class Client + include AutoLogger + TIMEOUT = 10 + API_URL = 'https://perfectmoney.is/acct' + + def initialize(account_id:, pass_phrase:, account:) + @account_id = account_id + @pass_phrase = pass_phrase + @account = account + end + + def create_payout(destination_account:, amount:, payment_id:) + safely_parse( + http_request( + url: "#{API_URL}/confirm.asp?", + method: :GET, + params: { + AccountID: account_id, + PassPhrase: pass_phrase, + Payer_Account: account, + Payee_Account: destination_account, + Amount: amount, + PAYMENT_ID: payment_id + } + ), + mode: :html + ) + end + + def find_transaction(payment_batch_number:) + safely_parse( + http_request( + url: "#{API_URL}/historycsv.asp?", + method: :GET, + params: { + batchfilter: payment_batch_number, + AccountID: account_id, + PassPhrase: pass_phrase, + startmonth: now_utc.month, + startday: now_utc.day, + startyear: now_utc.year, + endmonth: now_utc.month, + endday: now_utc.day, + endyear: now_utc.year + } + ), + mode: :csv + ) + end + + private + + attr_reader :account_id, :pass_phrase, :account + + def http_request(url:, method:, params: nil) + uri = URI.parse(url + params.to_query) + https = http(uri) + request = build_request(uri: uri, method: method, params: params) + logger.info "Request type: #{method} to #{uri} with payload #{request.body}" + https.request(request) + end + + def build_request(uri:, method:, params: nil) + request = if method == :POST + Net::HTTP::Post.new(uri.request_uri) + elsif method == :GET + Net::HTTP::Get.new(uri.request_uri) + else + raise "Запрос #{method} не поддерживается!" + end + request.body = URI.encode_www_form((params.present? ? params : {})) + request + end + + def http(uri) + Net::HTTP.start(uri.host, uri.port, + use_ssl: true, + verify_mode: OpenSSL::SSL::VERIFY_NONE, + open_timeout: TIMEOUT, + read_timeout: TIMEOUT) + end + + def safely_parse(response, mode:) + body = response.body + logger.info "Response: #{body}" + + if mode == :html + html_to_hash(body) + elsif mode == :csv + csv_to_hash(body) + end + rescue => err + logger.warn "Request failed #{response.class} #{response}" + Bugsnag.notify err do |report| + report.add_tab(:response, response_class: response.class, response_body: response) + end + response + end + + def html_to_hash(response) + result = {} + html = Nokogiri::HTML(response) + + html.xpath('//input[@type="hidden"]').each do |input| + result[input.attributes['name'].value] = input.attributes['value'].value + end + + result + end + + def csv_to_hash(response) + CSV.parse(response, headers: :first_row).map(&:to_h).first + end + + def now_utc + @now_utc ||= Time.now.utc + end + end +end diff --git a/lib/payment_services/perfect_money/invoice.rb b/lib/payment_services/perfect_money/invoice.rb index fd6cb4fc..5401020e 100644 --- a/lib/payment_services/perfect_money/invoice.rb +++ b/lib/payment_services/perfect_money/invoice.rb @@ -3,8 +3,8 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex class PaymentServices::PerfectMoney - class Invoice < ApplicationRecord - include Workflow + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord self.table_name = 'perfect_money_invoices' scope :ordered, -> { order(id: :desc) } diff --git a/lib/payment_services/perfect_money/invoicer.rb b/lib/payment_services/perfect_money/invoicer.rb index 4a6c7dcc..14e933a8 100644 --- a/lib/payment_services/perfect_money/invoicer.rb +++ b/lib/payment_services/perfect_money/invoicer.rb @@ -13,6 +13,7 @@ def create_invoice(money) def invoice_form_data invoice = Invoice.find_by!(order_public_id: order.public_id) routes_helper = Rails.application.routes.url_helpers + { url: 'https://perfectmoney.is/api/step1.asp', method: 'POST', @@ -23,9 +24,9 @@ def invoice_form_data PAYMENT_AMOUNT: format('%.2f', invoice.amount.to_f), PAYMENT_UNITS: invoice.amount.currency.to_s, STATUS_URL: "#{routes_helper.public_public_callbacks_api_root_url}/v1/perfect_money/receive_payment", - PAYMENT_URL: routes_helper.public_payment_status_success_url(order_id: order.public_id), + PAYMENT_URL: order.success_redirect, PAYMENT_URL_METHOD: 'GET', - NOPAYMENT_URL: routes_helper.public_payment_status_fail_url(order_id: order.public_id), + NOPAYMENT_URL: order.failed_redirect, NOPAYMENT_URL_METHOD: 'GET', SUGGESTED_MEMO: I18n.t('payment_systems.default_product', order_id: order.public_id), BAGGAGE_FIELDS: '', diff --git a/lib/payment_services/perfect_money/payout.rb b/lib/payment_services/perfect_money/payout.rb new file mode 100644 index 00000000..99938ed4 --- /dev/null +++ b/lib/payment_services/perfect_money/payout.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class PaymentServices::PerfectMoney + class Payout < PaymentServices::ApplicationRecord + include WorkflowActiverecord + + self.table_name = 'perfect_money_payouts' + + scope :ordered, -> { order(id: :desc) } + + monetize :amount_cents, as: :amount + validates :amount_cents, :destination_account, :state, presence: true + + workflow_column :state + workflow do + state :pending do + event :pay, transitions_to: :paid + end + state :paid do + event :confirm, transitions_to: :completed + event :fail, transitions_to: :failed + end + state :completed + state :failed + end + + def pay(payment_batch_number:) + update(payment_batch_number: payment_batch_number) + end + + def build_payment_id + "#{order_payout.order.public_id}-#{order_payout.id}" + end + + private + + def order_payout + @order_payout ||= OrderPayout.find(order_payout_id) + end + end +end diff --git a/lib/payment_services/perfect_money/payout_adapter.rb b/lib/payment_services/perfect_money/payout_adapter.rb new file mode 100644 index 00000000..0c05c425 --- /dev/null +++ b/lib/payment_services/perfect_money/payout_adapter.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::PerfectMoney + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + response = client.find_transaction(payment_batch_number: payout.payment_batch_number) + raise "Can't get withdrawal details" unless response + + payout.confirm! if response['Batch'] == payout.payment_batch_number + response + end + + private + + def make_payout(amount:, destination_account:, order_payout_id:) + payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + + response = client.create_payout(destination_account: destination_account, amount: amount.to_d.round(2), payment_id: payout.build_payment_id) + raise "Can't process payout: #{response['ERROR']}" if response['ERROR'] + + payout.pay!(payment_batch_number: response['PAYMENT_BATCH_NUM']) if response['PAYMENT_BATCH_NUM'] + end + + def client + @client ||= begin + Client.new(account_id: wallet.merchant_id, pass_phrase: api_key, account: wallet.account) + end + end + end +end diff --git a/lib/payment_services/qiwi/invoicer.rb b/lib/payment_services/qiwi/invoicer.rb index c3ff8a2a..6f2f98db 100644 --- a/lib/payment_services/qiwi/invoicer.rb +++ b/lib/payment_services/qiwi/invoicer.rb @@ -22,6 +22,8 @@ def pay_invoice_url amountInteger: whole_amount, amountFraction: fractional_amount, currency: QIWI_CURRENCY_RUB, + urlSuccess: order.success_redirect, + urlFailure: order.failed_redirect, "extra['comment']" => I18n.t('payment_systems.default_product', order_id: order.public_id), "extra['account']" => order.income_wallet.account }.to_query diff --git a/lib/payment_services/qiwi/payment.rb b/lib/payment_services/qiwi/payment.rb index e7cd2bf0..ec077e44 100644 --- a/lib/payment_services/qiwi/payment.rb +++ b/lib/payment_services/qiwi/payment.rb @@ -9,9 +9,8 @@ require_relative 'payment_order_support' class PaymentServices::QIWI - class Payment < ApplicationRecord + class Payment < PaymentServices::ApplicationRecord include AutoLogger - extend Enumerize include PaymentOrderSupport self.table_name = :qiwi_payments @@ -21,11 +20,11 @@ class Payment < ApplicationRecord scope :ordered, -> { order 'id desc, date desc' } monetize :total_cents, as: :total - enum status: %i[UNKNOWN WAITING SUCCESS ERROR] - enumerize :direction_type, in: %w[IN OUT], predicates: { prefix: true } + enum :status, %i[UNKNOWN WAITING SUCCESS ERROR] + enum :direction_type, { in: 'IN', out: 'OUT' } def success_in? - SUCCESS? && direction_type_IN? + SUCCESS? && in? end end end diff --git a/lib/payment_services/qiwi/payout_adapter.rb b/lib/payment_services/qiwi/payout_adapter.rb index 425c612a..aa2db466 100644 --- a/lib/payment_services/qiwi/payout_adapter.rb +++ b/lib/payment_services/qiwi/payout_adapter.rb @@ -18,7 +18,6 @@ def make_payout!(amount:, payment_card_details:, transaction_id:, destination_ac private - # rubocop:disable Lint/UnusedMethodArgument def make_payout(amount:, payment_card_details:, transaction_id:, destination_account:) # rubocop:enable Lint/UnusedMethodArgument @@ -30,7 +29,7 @@ def make_payout(amount:, payment_card_details:, transaction_id:, destination_acc end def client - @client ||= Client.new phone: wallet.qiwi_phone, token: wallet.api_key + @client ||= Client.new phone: wallet.qiwi_phone, token: api_key end end end diff --git a/lib/payment_services/rbk.rb b/lib/payment_services/rbk.rb index 97545ee1..1f02a2c8 100644 --- a/lib/payment_services/rbk.rb +++ b/lib/payment_services/rbk.rb @@ -3,16 +3,22 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex module PaymentServices - class RBK < Base + class Rbk < Base CHECKOUT_URL = 'https://checkout.rbk.money/v1/checkout.html' + + # Используем autoload для всех классов + autoload :Identity, 'payment_services/rbk/identity' + autoload :Wallet, 'payment_services/rbk/wallet' + autoload :PayoutDestination, 'payment_services/rbk/payout_destination' + autoload :Payout, 'payment_services/rbk/payout' + autoload :Payment, 'payment_services/rbk/payment' + autoload :Invoice, 'payment_services/rbk/invoice' + autoload :Customer, 'payment_services/rbk/customer' + autoload :PaymentCard, 'payment_services/rbk/payment_card' autoload :PayoutAdapter, 'payment_services/rbk/payout_adapter' autoload :Invoicer, 'payment_services/rbk/invoicer' + register :invoicer, Invoicer register :payout_adapter, PayoutAdapter end end -# FIXME: не знаю как проще подгрузить все файлы из rbk -require_relative 'rbk/identity' -require_relative 'rbk/wallet' -require_relative 'rbk/payout_destination' -require_relative 'rbk/payout' diff --git a/lib/payment_services/rbk/client.rb b/lib/payment_services/rbk/client.rb index b92aac5e..7e0b39bb 100644 --- a/lib/payment_services/rbk/client.rb +++ b/lib/payment_services/rbk/client.rb @@ -2,7 +2,7 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex -class PaymentServices::RBK +class PaymentServices::Rbk class Client include AutoLogger TIMEOUT = 10 diff --git a/lib/payment_services/rbk/customer.rb b/lib/payment_services/rbk/customer.rb index c5da1f23..07f8ceff 100644 --- a/lib/payment_services/rbk/customer.rb +++ b/lib/payment_services/rbk/customer.rb @@ -3,16 +3,15 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex require_relative 'customer_client' -require_relative 'payment_card' require 'jwt' -class PaymentServices::RBK - class Customer < ApplicationRecord +class PaymentServices::Rbk + class Customer < PaymentServices::ApplicationRecord self.table_name = 'rbk_money_customers' belongs_to :user has_many :payment_cards, - class_name: 'PaymentServices::RBK::PaymentCard', + class_name: 'PaymentServices::Rbk::PaymentCard', foreign_key: :rbk_customer_id, dependent: :destroy @@ -41,7 +40,7 @@ def self.expiration_time_from(token) def bind_payment_card_url refresh_token! unless access_token_valid? - uri = URI.parse(PaymentServices::RBK::CHECKOUT_URL) + uri = URI.parse(PaymentServices::Rbk::CHECKOUT_URL) query_hash = { customerID: rbk_id, customerAccessToken: access_token, diff --git a/lib/payment_services/rbk/customer_client.rb b/lib/payment_services/rbk/customer_client.rb index 2dc5b7b2..e6fadf76 100644 --- a/lib/payment_services/rbk/customer_client.rb +++ b/lib/payment_services/rbk/customer_client.rb @@ -4,8 +4,8 @@ require_relative 'client' -class PaymentServices::RBK - class CustomerClient < PaymentServices::RBK::Client +class PaymentServices::Rbk + class CustomerClient < PaymentServices::Rbk::Client URL = "#{API_V2}/processing/customers" def create_customer(user) diff --git a/lib/payment_services/rbk/identity.rb b/lib/payment_services/rbk/identity.rb index 5b6047a1..5f4e48f9 100644 --- a/lib/payment_services/rbk/identity.rb +++ b/lib/payment_services/rbk/identity.rb @@ -4,15 +4,15 @@ require_relative 'identity_client' -class PaymentServices::RBK - class Identity < ApplicationRecord +class PaymentServices::Rbk + class Identity < PaymentServices::ApplicationRecord self.table_name = 'rbk_identities' has_many :rbk_wallets, - class_name: 'PaymentServices::RBK::Wallet', + class_name: 'PaymentServices::Rbk::Wallet', foreign_key: :rbk_identity_id has_many :rbk_payout_destinations, - class_name: 'PaymentServices::RBK::PayoutDestination', + class_name: 'PaymentServices::Rbk::PayoutDestination', foreign_key: :rbk_identity_id def self.current diff --git a/lib/payment_services/rbk/identity_client.rb b/lib/payment_services/rbk/identity_client.rb index 70ea66d9..09d4b9be 100644 --- a/lib/payment_services/rbk/identity_client.rb +++ b/lib/payment_services/rbk/identity_client.rb @@ -4,8 +4,8 @@ require_relative 'client' -class PaymentServices::RBK - class IdentityClient < PaymentServices::RBK::Client +class PaymentServices::Rbk + class IdentityClient < PaymentServices::Rbk::Client URL = 'https://api.rbk.money/wallet/v0/identities' def create_sample_identity diff --git a/lib/payment_services/rbk/invoice.rb b/lib/payment_services/rbk/invoice.rb index 241237f1..1ef8223e 100644 --- a/lib/payment_services/rbk/invoice.rb +++ b/lib/payment_services/rbk/invoice.rb @@ -2,18 +2,17 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex -require_relative 'payment' require_relative 'invoice_client' -class PaymentServices::RBK - class Invoice < ApplicationRecord - include Workflow +class PaymentServices::Rbk + class Invoice < PaymentServices::ApplicationRecord + include WorkflowActiverecord self.table_name = 'rbk_money_invoices' scope :ordered, -> { order(id: :desc) } has_many :payments, - class_name: 'PaymentServices::RBK::Payment', + class_name: 'PaymentServices::Rbk::Payment', foreign_key: :rbk_money_invoice_id, dependent: :destroy @@ -69,6 +68,9 @@ def make_refund! def fetch_payments! response = InvoiceClient.new.get_payments(self) response.map { |payment_json| find_or_create_payment!(payment_json) } + rescue => e + Rails.logger.warn "Failed to fetch payments for invoice #{id}: #{e.message}" if defined?(Rails) + raise e end private @@ -77,12 +79,12 @@ def find_or_create_payment!(payment_json) payment = payments.find_by(rbk_id: payment_json['id']) return payment if payment.present? - Payment.create!( + PaymentServices::Rbk::Payment.create!( rbk_id: payment_json['id'], invoice: self, order_public_id: order_public_id, amount_in_cents: payment_json['amount'], - state: Payment.rbk_state_to_state(payment_json['status']), + state: PaymentServices::Rbk::Payment.rbk_state_to_state(payment_json['status']), payload: payment_json ) end diff --git a/lib/payment_services/rbk/invoice_client.rb b/lib/payment_services/rbk/invoice_client.rb index c29d316e..88b46794 100644 --- a/lib/payment_services/rbk/invoice_client.rb +++ b/lib/payment_services/rbk/invoice_client.rb @@ -4,8 +4,8 @@ require_relative 'client' -class PaymentServices::RBK - class InvoiceClient < PaymentServices::RBK::Client +class PaymentServices::Rbk + class InvoiceClient < PaymentServices::Rbk::Client URL = "#{API_V2}/processing/invoices" def create_invoice(order_id:, amount:) diff --git a/lib/payment_services/rbk/invoicer.rb b/lib/payment_services/rbk/invoicer.rb index 11cfaf3a..a77d65be 100644 --- a/lib/payment_services/rbk/invoicer.rb +++ b/lib/payment_services/rbk/invoicer.rb @@ -2,15 +2,14 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex -require_relative 'invoice' require_relative 'invoice_client' -require_relative 'customer' +require_relative 'customer_client' -class PaymentServices::RBK +class PaymentServices::Rbk class Invoicer < ::PaymentServices::Base::Invoicer def create_invoice(money) response = InvoiceClient.new.create_invoice(order_id: order.public_id, amount: money.cents) - Invoice.create!( + PaymentServices::Rbk::Invoice.create!( amount: money.to_f, order_public_id: order.public_id, rbk_invoice_id: response['invoice']['id'], @@ -20,7 +19,7 @@ def create_invoice(money) end def pay_invoice_url - uri = URI.parse(PaymentServices::RBK::CHECKOUT_URL) + uri = URI.parse(PaymentServices::Rbk::CHECKOUT_URL) query_hash = { invoiceID: invoice.rbk_invoice_id, invoiceAccessToken: invoice.access_token, @@ -43,16 +42,16 @@ def pay_invoice_url end def make_refund! - invoice = PaymentServices::RBK::Invoice.find_by!(order_public_id: order.public_id) + invoice = PaymentServices::Rbk::Invoice.find_by!(order_public_id: order.public_id) invoice.make_refund! end def payments - PaymentServices::RBK::Payment.where(order_public_id: order.public_id) + PaymentServices::Rbk::Payment.where(order_public_id: order.public_id) end def invoice - @invoice ||= PaymentServices::RBK::Invoice.find_by!(order_public_id: order.public_id) + @invoice ||= PaymentServices::Rbk::Invoice.find_by!(order_public_id: order.public_id) end def able_to_refund? diff --git a/lib/payment_services/rbk/payment.rb b/lib/payment_services/rbk/payment.rb index 3ac7ef16..f0acb924 100644 --- a/lib/payment_services/rbk/payment.rb +++ b/lib/payment_services/rbk/payment.rb @@ -4,9 +4,9 @@ require_relative 'payment_client' -class PaymentServices::RBK - class Payment < ApplicationRecord - include Workflow +class PaymentServices::Rbk + class Payment < PaymentServices::ApplicationRecord + include WorkflowActiverecord self.table_name = 'rbk_money_payments' scope :ordered, -> { order(id: :desc) } @@ -16,7 +16,7 @@ class Payment < ApplicationRecord validates :amount_in_cents, :rbk_id, :state, presence: true belongs_to :invoice, - class_name: 'PaymentServices::RBK::Invoice', + class_name: 'PaymentServices::Rbk::Invoice', foreign_key: :rbk_money_invoice_id delegate :access_token, to: :invoice @@ -50,7 +50,7 @@ def self.rbk_state_to_state(rbk_state) elsif PaymentClient::PENDING_STATES.include?(rbk_state) :pending elsif PaymentClient::REFUND_STATES.include?(rbk_state) - :fefunded + :refunded else raise("Такого статуса не существует: #{rbk_state}") end diff --git a/lib/payment_services/rbk/payment_card.rb b/lib/payment_services/rbk/payment_card.rb index 0acf86dd..5165772b 100644 --- a/lib/payment_services/rbk/payment_card.rb +++ b/lib/payment_services/rbk/payment_card.rb @@ -2,17 +2,20 @@ # Copyright (c) 2018 FINFEX https://github.com/finfex -class PaymentServices::RBK - class PaymentCard < ApplicationRecord +class PaymentServices::Rbk + class PaymentCard < PaymentServices::ApplicationRecord self.table_name = 'rbk_payment_cards' - enum card_type: %i[bank_card applepay googlepay], _prefix: :card_type + enum :card_type, %i[bank_card applepay googlepay], prefix: :card_type - belongs_to :rbk_customer, class_name: 'PaymentServices::RBK::Customer', foreign_key: :rbk_customer_id + belongs_to :rbk_customer, class_name: 'PaymentServices::Rbk::Customer', foreign_key: :rbk_customer_id def masked_number # NOTE dup нужен, т.к. insert изменяет исходный объект - "#{bin.dup.insert(4, ' ')}** **** #{last_digits}" + return ' ** **** ' if bin.empty? && last_digits.empty? + + bin_with_space = bin.dup.insert(4, ' ') + "#{bin_with_space}** **** #{last_digits}" end end end diff --git a/lib/payment_services/rbk/payment_client.rb b/lib/payment_services/rbk/payment_client.rb index ee08e666..fb9b7bb5 100644 --- a/lib/payment_services/rbk/payment_client.rb +++ b/lib/payment_services/rbk/payment_client.rb @@ -4,8 +4,8 @@ require_relative 'client' -class PaymentServices::RBK - class PaymentClient < PaymentServices::RBK::Client +class PaymentServices::Rbk + class PaymentClient < PaymentServices::Rbk::Client STATES = %w[pending processed captured cancelled refunded failed].freeze SUCCESS_STATES = %w[processed captured].freeze FAIL_STATES = %w[cancelled failed].freeze diff --git a/lib/payment_services/rbk/payout.rb b/lib/payment_services/rbk/payout.rb index b64a0873..921fac98 100644 --- a/lib/payment_services/rbk/payout.rb +++ b/lib/payment_services/rbk/payout.rb @@ -4,30 +4,32 @@ require_relative 'payout_client' -class PaymentServices::RBK - class Payout < ApplicationRecord +class PaymentServices::Rbk + class Payout < PaymentServices::ApplicationRecord self.table_name = 'rbk_payouts' Error = Class.new StandardError belongs_to :rbk_payout_destination, - class_name: 'PaymentServices::RBK::PayoutDestination', + class_name: 'PaymentServices::Rbk::PayoutDestination', foreign_key: :rbk_payout_destination_id belongs_to :rbk_wallet, - class_name: 'PaymentServices::RBK::Wallet', + class_name: 'PaymentServices::Rbk::Wallet', foreign_key: :rbk_wallet_id - def self.create_from!(destinaion:, wallet:, amount_cents:) + validates :rbk_id, :rbk_payout_destination, :rbk_wallet, :amount_cents, :rbk_status, presence: true, on: :create + + def self.create_from!(destination:, wallet:, amount_cents:) response = PayoutClient.new.make_payout( - payout_destination: destinaion, + payout_destination: destination, wallet: wallet, amount_cents: amount_cents ) - raise Error, "RBK payout error: #{response}" unless response['status'] + raise Error, "Rbk payout error: #{response}" unless response['status'] create!( rbk_id: response['id'], - rbk_payout_destination: destinaion, + rbk_payout_destination: destination, rbk_wallet: wallet, amount_cents: amount_cents, payload: response, diff --git a/lib/payment_services/rbk/payout_adapter.rb b/lib/payment_services/rbk/payout_adapter.rb index 72397ec3..bd15186c 100644 --- a/lib/payment_services/rbk/payout_adapter.rb +++ b/lib/payment_services/rbk/payout_adapter.rb @@ -5,10 +5,9 @@ require_relative 'client' # Сервис выплаты на карты с помощью РБК # -class PaymentServices::RBK +class PaymentServices::Rbk class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter # TODO: возможность передавть ID кошелька для списания - # rubocop:disable Lint/UnusedMethodArgument def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:) # rubocop:enable Lint/UnusedMethodArgument raise 'Можно делать выплаты только в рублях' unless amount.currency == RUB @@ -28,7 +27,7 @@ def make_payout!(amount:, payment_card_details:, transaction_id:, destination_ac end Payout.create_from!( - destinaion: payout_destination, + destination: payout_destination, wallet: identity.current_wallet, amount_cents: amount.cents ) diff --git a/lib/payment_services/rbk/payout_client.rb b/lib/payment_services/rbk/payout_client.rb index 5920fda0..a770971c 100644 --- a/lib/payment_services/rbk/payout_client.rb +++ b/lib/payment_services/rbk/payout_client.rb @@ -4,8 +4,8 @@ require_relative 'client' -class PaymentServices::RBK - class PayoutClient < PaymentServices::RBK::Client +class PaymentServices::Rbk + class PayoutClient < PaymentServices::Rbk::Client URL = 'https://api.rbk.money/wallet/v0/withdrawals' def make_payout(payout_destination:, wallet:, amount_cents:) @@ -17,7 +17,7 @@ def make_payout(payout_destination:, wallet:, amount_cents:) destination: payout_destination.rbk_id, body: { amount: amount_cents, - currency: 'RUB' # RBK выводит только рубли + currency: 'RUB' # Rbk выводит только рубли } } ) diff --git a/lib/payment_services/rbk/payout_destination.rb b/lib/payment_services/rbk/payout_destination.rb index d644243b..22e54ddd 100644 --- a/lib/payment_services/rbk/payout_destination.rb +++ b/lib/payment_services/rbk/payout_destination.rb @@ -4,12 +4,14 @@ require_relative 'payout_destination_client' -class PaymentServices::RBK - class PayoutDestination < ApplicationRecord +class PaymentServices::Rbk + class PayoutDestination < PaymentServices::ApplicationRecord self.table_name = 'rbk_payout_destinations' Error = Class.new StandardError - belongs_to :rbk_identity, class_name: 'PaymentServices::RBK::Identity', foreign_key: :rbk_identity_id + belongs_to :rbk_identity, class_name: 'PaymentServices::Rbk::Identity', foreign_key: :rbk_identity_id + + validates :rbk_id, :public_id, :card_brand, :card_bin, :card_suffix, :payment_token, :rbk_status, :rbk_identity, presence: true, on: :create def self.find_or_create_from_card_details(number:, name:, exp_date:, identity:) tokenized_card = tokenize_card!(number: number, name: name, exp_date: exp_date) @@ -29,7 +31,7 @@ def self.create_destination!(identity:, tokenized_card:) payment_token: tokenized_card['token'], destination_public_id: public_id ) - raise Error, "RBK failed to create destinaion: #{response}" unless response['id'] + raise Error, "Rbk failed to create destination: #{response}" unless response['id'] create!( rbk_identity: identity, @@ -46,7 +48,7 @@ def self.create_destination!(identity:, tokenized_card:) def self.tokenize_card!(number:, name:, exp_date:) response = PayoutDestinationClient.new.tokenize_card(number: number, name: name, exp_date: exp_date) - raise Error, "RBK tokenization error: #{response}" unless response && response['token'].present? + raise Error, "Rbk tokenization error: #{response}" unless response && response['token'].present? response end diff --git a/lib/payment_services/rbk/payout_destination_client.rb b/lib/payment_services/rbk/payout_destination_client.rb index b2556a03..4abbae3f 100644 --- a/lib/payment_services/rbk/payout_destination_client.rb +++ b/lib/payment_services/rbk/payout_destination_client.rb @@ -4,8 +4,8 @@ require_relative 'client' -class PaymentServices::RBK - class PayoutDestinationClient < PaymentServices::RBK::Client +class PaymentServices::Rbk + class PayoutDestinationClient < PaymentServices::Rbk::Client TOKENIZE_URL = 'https://api.rbk.money/payres/v0/bank-cards' URL = 'https://api.rbk.money/wallet/v0/destinations' diff --git a/lib/payment_services/rbk/wallet.rb b/lib/payment_services/rbk/wallet.rb index b9fb9ce8..a413e052 100644 --- a/lib/payment_services/rbk/wallet.rb +++ b/lib/payment_services/rbk/wallet.rb @@ -4,11 +4,11 @@ require_relative 'wallet_client' -class PaymentServices::RBK - class Wallet < ApplicationRecord +class PaymentServices::Rbk + class Wallet < PaymentServices::ApplicationRecord self.table_name = 'rbk_wallets' - belongs_to :rbk_identity, class_name: 'PaymentServices::RBK::Identity', foreign_key: :rbk_identity_id + belongs_to :rbk_identity, class_name: 'PaymentServices::Rbk::Identity', foreign_key: :rbk_identity_id def self.create_for_identity(identity) response = WalletClient.new.create_wallet(identity: identity) diff --git a/lib/payment_services/rbk/wallet_client.rb b/lib/payment_services/rbk/wallet_client.rb index 26fe9994..c57ec2df 100644 --- a/lib/payment_services/rbk/wallet_client.rb +++ b/lib/payment_services/rbk/wallet_client.rb @@ -4,8 +4,8 @@ require_relative 'client' -class PaymentServices::RBK - class WalletClient < PaymentServices::RBK::Client +class PaymentServices::Rbk + class WalletClient < PaymentServices::Rbk::Client URL = 'https://api.rbk.money/wallet/v0/wallets' def create_wallet(identity:) diff --git a/lib/payment_services/transfera.rb b/lib/payment_services/transfera.rb new file mode 100644 index 00000000..063920cc --- /dev/null +++ b/lib/payment_services/transfera.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class Transfera < Base + autoload :Invoicer, 'payment_services/transfera/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/transfera/client.rb b/lib/payment_services/transfera/client.rb new file mode 100644 index 00000000..2320aa24 --- /dev/null +++ b/lib/payment_services/transfera/client.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class PaymentServices::Transfera + class Client < ::PaymentServices::Base::Client + API_URL = 'https://api.transfera.io' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/integrations/ru/transactions/new/", + method: :POST, + body: params.merge(merchantToken: api_key, hmacHash: signature(params)).to_json, + headers: build_headers + ) + end + + def transaction(transaction_id:) + safely_parse http_request( + url: "#{API_URL}/integrations/ru/transactions/#{transaction_id}", + method: :GET, + headers: build_headers + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers + { + 'Content-Type' => 'application/json' + } + end + + def signature(params) + OpenSSL::HMAC.hexdigest('SHA512', secret_key, [params[:amount], params[:currency], api_key].join('::')) + end + end +end diff --git a/lib/payment_services/transfera/invoice.rb b/lib/payment_services/transfera/invoice.rb new file mode 100644 index 00000000..b80bea99 --- /dev/null +++ b/lib/payment_services/transfera/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::Transfera + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'Success' + FAILED_PROVIDER_STATE = 'Fail' + + self.table_name = 'transferas_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/transfera/invoicer.rb b/lib/payment_services/transfera/invoicer.rb new file mode 100644 index 00000000..409b6c22 --- /dev/null +++ b/lib/payment_services/transfera/invoicer.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Transfera + class Invoicer < ::PaymentServices::Base::Invoicer + SBP_PAYMENT_METHOD = 'SBP' + Error = Class.new StandardError + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_invoice(params: invoice_params) + raise Error, "Can't create invoice" unless response.dig('data', 'order_id') + + invoice.update!(deposit_id: response.dig('data', 'order_id')) + PaymentServices::Base::Wallet.new( + address: response.dig('data', 'card'), + name: nil + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.transaction(transaction_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction.dig('data', 'status')) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :card_bank, :sbp?, to: :bank_resolver + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_params + { + amount: invoice.amount.to_f, + currency: invoice.amount_currency.to_s, + payload: { + id: order.public_id.to_s + }, + cardType: sbp? ? SBP_PAYMENT_METHOD : card_bank, + transaction_type: 'PAY_IN' + } + end + + def bank_resolver + @bank_resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/tronscan.rb b/lib/payment_services/tronscan.rb new file mode 100644 index 00000000..e0c73167 --- /dev/null +++ b/lib/payment_services/tronscan.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class Tronscan < Base + autoload :Invoicer, 'payment_services/tronscan/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/tronscan/client.rb b/lib/payment_services/tronscan/client.rb new file mode 100644 index 00000000..72fea35d --- /dev/null +++ b/lib/payment_services/tronscan/client.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class PaymentServices::Tronscan + class Client < ::PaymentServices::Base::Client + API_URL = 'https://apilist.tronscanapi.com/api' + USDT_TRC_CONTRACT_ADDRESS = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + CURRENCY_TO_ENDPOINT = { + 'trx' => 'transaction', + 'usdt' => 'new/token_trc20/transfers' + }.freeze + + def initialize(api_key:, currency:) + @api_key = api_key + @currency = currency.inquiry + end + + def transactions(address:) + if currency.usdt? + params = { toAddress: address, contract_address: USDT_TRC_CONTRACT_ADDRESS } + else + params = { address: address, limit: 100 } + end + + params = params.to_query + response = safely_parse(http_request( + url: "#{API_URL}/#{endpoint}?#{params}", + method: :GET, + headers: build_headers + )) + response['data'] || response['token_transfers'] + end + + private + + attr_reader :api_key, :currency + + def build_headers + { + 'TRON-PRO-API-KEY' => api_key + } + end + + def endpoint + CURRENCY_TO_ENDPOINT[currency] || raise("#{currency} is not supported") + end + end +end diff --git a/lib/payment_services/tronscan/invoice.rb b/lib/payment_services/tronscan/invoice.rb new file mode 100644 index 00000000..be14bfeb --- /dev/null +++ b/lib/payment_services/tronscan/invoice.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class PaymentServices::Tronscan + class Invoice < ::PaymentServices::Base::CryptoInvoice + self.table_name = 'tronscan_invoices' + + monetize :amount_cents, as: :amount + + def update_invoice_details(transaction:) + bind_transaction! if pending? + update!(transaction_created_at: transaction.created_at, transaction_id: transaction.id) + + pay!(payload: transaction) if transaction.successful? + end + end +end diff --git a/lib/payment_services/tronscan/invoicer.rb b/lib/payment_services/tronscan/invoicer.rb new file mode 100644 index 00000000..bd962cf4 --- /dev/null +++ b/lib/payment_services/tronscan/invoicer.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' +require_relative 'transaction_matcher' + +class PaymentServices::Tronscan + class Invoicer < ::PaymentServices::Base::Invoicer + def create_invoice(money) + Invoice.create!(amount: money, order_public_id: order.public_id, address: order.income_account_emoney) + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = transaction_for(invoice) + return if transaction.nil? + + invoice.update_invoice_details(transaction: transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :income_payment_system, to: :order + delegate :currency, to: :income_payment_system + + def transaction_for(invoice) + TransactionMatcher.new(invoice: invoice, transactions: collect_transactions).perform + end + + def collect_transactions + client.transactions(address: invoice.address) + end + + def client + @client ||= Client.new(api_key: api_key, currency: currency.to_s.downcase) + end + end +end diff --git a/lib/payment_services/tronscan/transaction.rb b/lib/payment_services/tronscan/transaction.rb new file mode 100644 index 00000000..236ac924 --- /dev/null +++ b/lib/payment_services/tronscan/transaction.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class PaymentServices::Tronscan + class Transaction + include Virtus.model + + attribute :id, String + attribute :created_at, DateTime + attribute :source, Hash + + def self.build_from(raw_transaction:) + new( + id: raw_transaction[:id], + created_at: raw_transaction[:created_at], + source: raw_transaction[:source].deep_symbolize_keys + ) + end + + def to_s + source.to_s + end + + def successful? + return false if source.nil? || source.empty? + source[:confirmed] + end + end +end diff --git a/lib/payment_services/tronscan/transaction_matcher.rb b/lib/payment_services/tronscan/transaction_matcher.rb new file mode 100644 index 00000000..9eb6f484 --- /dev/null +++ b/lib/payment_services/tronscan/transaction_matcher.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative 'transaction' + +class PaymentServices::Tronscan + class TransactionMatcher + def initialize(invoice:, transactions:) + @invoice = invoice + @transactions = transactions + @currency = invoice.amount_currency.to_s.downcase + end + + def perform + send("match_#{currency}_transaction") + end + + private + + attr_reader :invoice, :transactions, :currency + + def build_transaction(id:, created_at:, source:) + Transaction.build_from(raw_transaction: { id: id, created_at: created_at, source: source }) + end + + def match_trx_transaction + raw_transaction = transactions.find { |transaction| match_trx_transaction?(transaction) } + return unless raw_transaction + + build_transaction( + id: raw_transaction['hash'], + created_at: timestamp_in_utc(raw_transaction['timestamp'] / 1000), + source: raw_transaction + ) + end + + def match_trx_transaction?(transaction) + match_amount?(transaction['amount'], transaction['tokenInfo']['tokenDecimal']) && match_time?(transaction['timestamp'] / 1000) + end + + def match_usdt_transaction + raw_transaction = transactions.find { |transaction| match_usdt_transaction?(transaction) } + return unless raw_transaction + + build_transaction( + id: raw_transaction['transaction_id'], + created_at: timestamp_in_utc(raw_transaction['block_ts'] / 1000), + source: raw_transaction + ) + end + + def match_usdt_transaction?(transaction) + match_amount?(transaction['quant'], transaction['tokenInfo']['tokenDecimal']) && match_time?(transaction['block_ts'] / 1000) + end + + def match_amount?(received_amount, decimals) + amount = received_amount.to_i / 10.0 ** decimals + amount == invoice.amount.to_f + end + + def match_time?(timestamp) + invoice.created_at.utc < timestamp_in_utc(timestamp) + end + + def timestamp_in_utc(timestamp) + Time.at(timestamp).to_datetime.utc + end + end +end diff --git a/lib/payment_services/wallex.rb b/lib/payment_services/wallex.rb new file mode 100644 index 00000000..2f1f953d --- /dev/null +++ b/lib/payment_services/wallex.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class Wallex < Base + autoload :Invoicer, 'payment_services/wallex/invoicer' + autoload :PayoutAdapter, 'payment_services/wallex/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/wallex/client.rb b/lib/payment_services/wallex/client.rb new file mode 100644 index 00000000..a9bed00a --- /dev/null +++ b/lib/payment_services/wallex/client.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class PaymentServices::Wallex + class Client < ::PaymentServices::Base::Client + API_URL = 'https://wallex.online' + MERCHANT_ID = 286 + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_invoice(params:) + sign = signature(sign_str: sign_string(params: params, param_names: [:client, :uuid, :amount, :fiat_currency, :payment_method])) + safely_parse http_request( + url: "#{API_URL}/exchange/create_deal_v2/#{MERCHANT_ID}", + method: :POST, + body: params.merge(sign: sign).to_json, + headers: build_headers + ) + end + + def invoice_transaction(deposit_id:) + safely_parse http_request( + url: "#{API_URL}/exchange/get?id=#{deposit_id}", + method: :GET, + headers: build_headers + ) + end + + def create_payout(params:) + params[:merchant] = MERCHANT_ID + sign = signature(sign_str: sign_string(params: params, param_names: [:merchant, :amount, :currency, :number, :bank, :type, :fiat])) + safely_parse http_request( + url: "#{API_URL}/payout/new", + method: :POST, + body: params.merge(sign: sign).to_json, + headers: build_headers + ) + end + + def payout_transaction(payout_id:) + params = { merchant: MERCHANT_ID, id: payout_id } + sign = signature(sign_str: sign_string(params: params, param_names: [:merchant, :id])) + safely_parse http_request( + url: "#{API_URL}/payout/get", + method: :POST, + body: params.merge(sign: sign).to_json, + headers: build_headers + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_headers + { + 'Content-Type' => 'application/json', + 'X-Api-Key' => api_key + } + end + + def sign_string(params:, param_names:) + params.slice(*param_names).values.join + secret_key + end + + def signature(sign_str:) + Digest::SHA1.hexdigest(sign_str) + end + end +end diff --git a/lib/payment_services/wallex/invoice.rb b/lib/payment_services/wallex/invoice.rb new file mode 100644 index 00000000..ccd42613 --- /dev/null +++ b/lib/payment_services/wallex/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::Wallex + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 4 + FAILED_PROVIDER_STATE = 2 + + self.table_name = 'wallex_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/wallex/invoicer.rb b/lib/payment_services/wallex/invoicer.rb new file mode 100644 index 00000000..9e84a7bb --- /dev/null +++ b/lib/payment_services/wallex/invoicer.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::Wallex + class Invoicer < ::PaymentServices::Base::Invoicer + SBP_PAYMENT_METHOD = 'sbp' + CARD_PAYMENT_METHOD = 'c2c' + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_invoice(params: invoice_p2p_params) + raise response['message'] unless response['success'] + + invoice.update!(deposit_id: response['id']) + PaymentServices::Base::Wallet.new( + address: response.dig('paymentInfo', 'paymentCredentials'), + name: nil, + memo: response.dig('paymentInfo', 'paymentComment') + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.invoice_transaction(deposit_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction['status']) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + delegate :card_bank, :sbp_bank, :sbp?, to: :bank_resolver + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_p2p_params + params = { + client: order.user.email, + amount: invoice.amount.to_f.to_s, + fiat_currency: invoice.amount_currency.to_s.downcase, + uuid: order.public_id.to_s, + payment_method: sbp? ? SBP_PAYMENT_METHOD : CARD_PAYMENT_METHOD + } + params[:bank] = sbp_bank if sbp? && sbp_bank.present? + params[:bank] = card_bank unless sbp? + params + end + + def bank_resolver + @bank_resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/wallex/payout.rb b/lib/payment_services/wallex/payout.rb new file mode 100644 index 00000000..0a4c5da8 --- /dev/null +++ b/lib/payment_services/wallex/payout.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::Wallex + class Payout < ::PaymentServices::Base::FiatPayout + SUCCESS_PROVIDER_STATE = 3 + FAILED_PROVIDER_STATE = 2 + + self.table_name = 'wallex_payouts' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state == FAILED_PROVIDER_STATE + end + end +end diff --git a/lib/payment_services/wallex/payout_adapter.rb b/lib/payment_services/wallex/payout_adapter.rb new file mode 100644 index 00000000..f6528252 --- /dev/null +++ b/lib/payment_services/wallex/payout_adapter.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::Wallex + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + PAYOUT_SUCCESS_STATE = 'success' + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + transaction = client.payout_transaction(payout_id: payout.withdrawal_id) + payout.update_state_by_provider(transaction.dig('item', 'status')) if transaction + transaction + end + + private + + delegate :card_bank, :sbp_bank, :sbp?, to: :bank_resolver + + attr_reader :payout + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + response = client.create_payout(params: payout_params) + raise response['error'] unless response['status'] == PAYOUT_SUCCESS_STATE + + payout.pay!(withdrawal_id: response['id']) + end + + def payout_params + order = OrderPayout.find(payout.order_payout_id).order + params = { + uuid: "#{Rails.env}_#{payout.id}", + amount: payout.amount.to_f.to_s, + currency: 'rub', + type: 'fiat', + bank: card_bank, + fiat: 'rub' + } + params[:number] = sbp? ? order.outcome_phone : order.destination_account + params[:bankCode] = sbp_bank if sbp? + params + end + + def bank_resolver + @bank_resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/x_pay_pro.rb b/lib/payment_services/x_pay_pro.rb new file mode 100644 index 00000000..f21dc52c --- /dev/null +++ b/lib/payment_services/x_pay_pro.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class XPayPro < Base + autoload :Invoicer, 'payment_services/x_pay_pro/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/x_pay_pro/client.rb b/lib/payment_services/x_pay_pro/client.rb new file mode 100644 index 00000000..e09e034d --- /dev/null +++ b/lib/payment_services/x_pay_pro/client.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PaymentServices::XPayPro + class Client < ::PaymentServices::Base::Client + API_URL = 'https://api.xpaypro.dev/v1/merchant-api' + + def initialize(api_key:) + @api_key = api_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/txs/p2p/invoice", + method: :POST, + body: params.to_json, + headers: build_headers + ) + end + + def transaction(deposit_id:) + safely_parse http_request( + url: "#{API_URL}/txs/#{deposit_id}", + method: :GET, + headers: build_headers + ) + end + + private + + attr_reader :api_key + + def build_headers + { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{api_key}" + } + end + end +end diff --git a/lib/payment_services/x_pay_pro/invoice.rb b/lib/payment_services/x_pay_pro/invoice.rb new file mode 100644 index 00000000..1c73324f --- /dev/null +++ b/lib/payment_services/x_pay_pro/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::XPayPro + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'TX_SUCCESS' + FAILED_PROVIDER_STATES = %w(TX_CANCELLED TX_EXPIRED) + + self.table_name = 'xpaypro_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state.in? FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/x_pay_pro/invoicer.rb b/lib/payment_services/x_pay_pro/invoicer.rb new file mode 100644 index 00000000..6b8f1aa7 --- /dev/null +++ b/lib/payment_services/x_pay_pro/invoicer.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::XPayPro + class Invoicer < ::PaymentServices::Base::Invoicer + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_invoice(params: invoice_p2p_params) + raise response['message'] if response['code'] + + invoice.update!( + deposit_id: response.dig('tx', 'tx_id'), + rate: response.dig('tx', 'rate').to_f + ) + PaymentServices::Base::Wallet.new( + address: response.dig('tx', 'payment_requisite'), + name: nil, + memo: response.dig('tx', 'payment_system').downcase.capitalize + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.transaction(deposit_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction.dig('tx', 'tx_status')) if transaction_valid?(transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_p2p_params + { + fiat_currency: 'RUB', + fiat_amount: invoice.amount.to_f.to_s, + crypto_currency: 'USDT', + payment_method: 'BANK_CARD', + bank_name: provider_bank, + merchant_tx_id: order.public_id.to_s, + merchant_client_id: "#{Rails.env}_user_id_#{order.user_id}" + } + end + + def transaction_valid?(transaction) + amount_confirmed = transaction.dig('tx', 'in_amount_confirmed') + amount_confirmed.to_f == invoice.amount.to_f || amount_confirmed == '0' + end + + def provider_bank + @provider_bank ||= PaymentServices::Base::P2pBankResolver.new(adapter: self).card_bank + end + + def client + @client ||= Client.new(api_key: api_key) + end + end +end diff --git a/lib/payment_services/x_pay_pro_virtual.rb b/lib/payment_services/x_pay_pro_virtual.rb new file mode 100644 index 00000000..a9ca1a98 --- /dev/null +++ b/lib/payment_services/x_pay_pro_virtual.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module PaymentServices + class XPayProVirtual < Base + autoload :Invoicer, 'payment_services/x_pay_pro_virtual/invoicer' + register :invoicer, Invoicer + end +end diff --git a/lib/payment_services/x_pay_pro_virtual/client.rb b/lib/payment_services/x_pay_pro_virtual/client.rb new file mode 100644 index 00000000..70f758ec --- /dev/null +++ b/lib/payment_services/x_pay_pro_virtual/client.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PaymentServices::XPayProVirtual + class Client < ::PaymentServices::Base::Client + API_URL = 'https://api.xpaypro.dev/v1/merchant-api' + + def initialize(api_key:) + @api_key = api_key + end + + def create_invoice(params:) + safely_parse http_request( + url: "#{API_URL}/txs/p2p/invoice", + method: :POST, + body: params.to_json, + headers: build_headers + ) + end + + def transaction(deposit_id:) + safely_parse http_request( + url: "#{API_URL}/txs/#{deposit_id}", + method: :GET, + headers: build_headers + ) + end + + private + + attr_reader :api_key + + def build_headers + { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{api_key}" + } + end + end +end diff --git a/lib/payment_services/x_pay_pro_virtual/invoice.rb b/lib/payment_services/x_pay_pro_virtual/invoice.rb new file mode 100644 index 00000000..e55321d8 --- /dev/null +++ b/lib/payment_services/x_pay_pro_virtual/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::XPayProVirtual + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATE = 'TX_SUCCESS' + FAILED_PROVIDER_STATES = %w(TX_CANCELLED TX_EXPIRED) + + self.table_name = 'xpaypro_virtual_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state == SUCCESS_PROVIDER_STATE + end + + def provider_failed? + provider_state.in? FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/x_pay_pro_virtual/invoicer.rb b/lib/payment_services/x_pay_pro_virtual/invoicer.rb new file mode 100644 index 00000000..f7f83454 --- /dev/null +++ b/lib/payment_services/x_pay_pro_virtual/invoicer.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::XPayProVirtual + class Invoicer < ::PaymentServices::Base::Invoicer + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + response = client.create_invoice(params: invoice_p2p_params) + raise response['message'] if response['code'] + + invoice.update!( + deposit_id: response.dig('tx', 'tx_id'), + rate: response.dig('tx', 'rate').to_f + ) + PaymentServices::Base::Wallet.new( + address: response.dig('tx', 'payment_requisite'), + name: nil, + memo: response.dig('tx', 'payment_system').downcase.capitalize + ) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.transaction(deposit_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction.dig('tx', 'tx_status')) if transaction_valid?(transaction) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + private + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + end + + def invoice_p2p_params + { + fiat_currency: 'RUB', + fiat_amount: invoice.amount.to_f.to_s, + crypto_currency: 'USDT', + payment_method: 'BANK_ACCOUNT', + bank_name: provider_bank, + merchant_tx_id: order.public_id.to_s, + merchant_client_id: "#{Rails.env}_user_id_#{order.user_id}" + } + end + + def transaction_valid?(transaction) + amount_confirmed = transaction.dig('tx', 'in_amount_confirmed') + amount_confirmed.to_f == invoice.amount.to_f || amount_confirmed == '0' + end + + def provider_bank + @provider_bank ||= PaymentServices::Base::P2pBankResolver.new(adapter: self).card_bank + end + + def client + @client ||= Client.new(api_key: api_key) + end + end +end diff --git a/lib/payment_services/your_payments.rb b/lib/payment_services/your_payments.rb new file mode 100644 index 00000000..d8b59ede --- /dev/null +++ b/lib/payment_services/your_payments.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module PaymentServices + class YourPayments < Base + autoload :Invoicer, 'payment_services/your_payments/invoicer' + autoload :PayoutAdapter, 'payment_services/your_payments/payout_adapter' + register :invoicer, Invoicer + register :payout_adapter, PayoutAdapter + end +end diff --git a/lib/payment_services/your_payments/client.rb b/lib/payment_services/your_payments/client.rb new file mode 100644 index 00000000..202aded8 --- /dev/null +++ b/lib/payment_services/your_payments/client.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class PaymentServices::YourPayments + class Client < ::PaymentServices::Base::Client + API_URL = 'https://yourpayment.pro/api' + + def initialize(api_key:, secret_key:) + @api_key = api_key + @secret_key = secret_key + end + + def create_provider_transaction(params:) + params.merge!(merchant_id: api_key) + safely_parse http_request( + url: "#{API_URL}/merchant-api/create-order", + method: :POST, + body: params.merge(signature: build_signature(params)).to_json, + headers: build_headers + ) + end + + def request_payment_details(params:) + safely_parse http_request( + url: "#{API_URL}/public/execute", + method: :POST, + body: params.to_json, + headers: build_headers + ) + end + + def payment_details(invoice_id:) + params = { order_id: invoice_id } + safely_parse http_request( + url: "#{API_URL}/public/order-details", + method: :POST, + body: params.to_json, + headers: build_headers + ) + end + + def confirm_payment(deposit_id:) + params = { order_id: deposit_id } + safely_parse http_request( + url: "#{API_URL}/public/mark-paid", + method: :POST, + body: params.to_json, + headers: build_headers + ) + end + + def transaction(transaction_id:) + params = { merchant_id: api_key, order_id: transaction_id } + safely_parse http_request( + url: "#{API_URL}/merchant-api/get-order", + method: :POST, + body: params.merge(signature: build_signature(params)).to_json, + headers: build_headers + ) + end + + private + + attr_reader :api_key, :secret_key + + def build_signature(params) + Digest::MD5.hexdigest("#{secret_key}+#{params.to_json}") + end + + def build_headers + { + 'Content-Type' => 'application/json' + } + end + end +end diff --git a/lib/payment_services/your_payments/invoice.rb b/lib/payment_services/your_payments/invoice.rb new file mode 100644 index 00000000..8c2f59cf --- /dev/null +++ b/lib/payment_services/your_payments/invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PaymentServices::YourPayments + class Invoice < ::PaymentServices::Base::FiatInvoice + SUCCESS_PROVIDER_STATES = %w(FINISHED_SUCCESS FINISHED_SUCCESS_RECALC) + FAILED_PROVIDER_STATES = %w(FINISHED_REJECTED FINISHED_EXPIRED FINISHED_CANCELED) + + self.table_name = 'your_payments_invoices' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state.in? SUCCESS_PROVIDER_STATES + end + + def provider_failed? + provider_state.in? FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/your_payments/invoicer.rb b/lib/payment_services/your_payments/invoicer.rb new file mode 100644 index 00000000..e3792915 --- /dev/null +++ b/lib/payment_services/your_payments/invoicer.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require_relative 'invoice' +require_relative 'client' + +class PaymentServices::YourPayments + class Invoicer < ::PaymentServices::Base::Invoicer + PROVIDER_REQUISITES_FOUND_STATE = 'TRADER_ACCEPTED' + PROVIDER_REQUEST_RETRIES = 3 + CARD_METHOD_TYPE = 'card_number' + SBP_METHOD_TYPE = 'phone_number' + + Error = Class.new StandardError + + def prepare_invoice_and_get_wallet!(currency:, token_network:) + create_invoice! + card_number, card_holder, bank_name = fetch_card_details! + + PaymentServices::Base::Wallet.new(address: card_number, name: card_holder, memo: bank_name) + end + + def create_invoice(money) + invoice + end + + def async_invoice_state_updater? + true + end + + def update_invoice_state! + transaction = client.transaction(transaction_id: invoice.deposit_id) + invoice.update_state_by_provider(transaction['status']) + end + + def invoice + @invoice ||= Invoice.find_by(order_public_id: order.public_id) + end + + def confirm_payment + client.confirm_payment(deposit_id: invoice.deposit_id) + end + + private + + delegate :card_bank, :sbp_bank, :sbp?, to: :resolver + delegate :income_payment_system, :income_unk, to: :order + delegate :currency, to: :income_payment_system + + def invoice_params + { + type: 'buy', + amount: invoice.amount_cents, + currency: currency.to_s, + method_type: method_type, + customer_id: order.user_id.to_s, + invoice_id: order.public_id.to_s + } + end + + def create_invoice! + Invoice.create!(amount: order.calculated_income_money, order_public_id: order.public_id) + response = client.create_provider_transaction(params: invoice_params) + + raise Error, "Can't create invoice: #{response}" unless response['order_id'] + invoice.update!(deposit_id: response['order_id']) + end + + def fetch_card_details! + status = request_trader + raise Error, 'Нет доступных реквизитов для оплаты' unless status == PROVIDER_REQUISITES_FOUND_STATE + + payment_details = client.payment_details(invoice_id: invoice.deposit_id) + number = payment_details['card'] + number = prepare_phone_number(number) if sbp? + + [number, payment_details['holder'], payment_details['bank']] + end + + def request_trader + PROVIDER_REQUEST_RETRIES.times do + sleep 2 + + params = { order_id: invoice.deposit_id } + params[:bank] = sbp_bank if sbp? && sbp_bank.present? + params[:bank] = card_bank unless sbp? + status = client.request_payment_details(params: params) + break status if status == PROVIDER_REQUISITES_FOUND_STATE + end + end + + def resolver + @resolver ||= PaymentServices::Base::P2pBankResolver.new(adapter: self) + end + + def method_type + sbp? ? SBP_METHOD_TYPE : CARD_METHOD_TYPE + end + + def prepare_phone_number(provider_phone_number) + "#{provider_phone_number[0..1]} (#{provider_phone_number[3..5]}) #{provider_phone_number[7..9]}-#{provider_phone_number[11..12]}-#{provider_phone_number[14..15]}" + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/lib/payment_services/your_payments/payout.rb b/lib/payment_services/your_payments/payout.rb new file mode 100644 index 00000000..4cecb232 --- /dev/null +++ b/lib/payment_services/your_payments/payout.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PaymentServices::YourPayments + class Payout < ::PaymentServices::Base::FiatPayout + self.table_name = 'your_payments_payouts' + + monetize :amount_cents, as: :amount + + private + + def provider_succeed? + provider_state.in? Invoice::SUCCESS_PROVIDER_STATES + end + + def provider_failed? + provider_state.in? Invoice::FAILED_PROVIDER_STATES + end + end +end diff --git a/lib/payment_services/your_payments/payout_adapter.rb b/lib/payment_services/your_payments/payout_adapter.rb new file mode 100644 index 00000000..3988e91b --- /dev/null +++ b/lib/payment_services/your_payments/payout_adapter.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative 'payout' +require_relative 'client' + +class PaymentServices::YourPayments + class PayoutAdapter < ::PaymentServices::Base::PayoutAdapter + Error = Class.new StandardError + + def make_payout!(amount:, payment_card_details:, transaction_id:, destination_account:, order_payout_id:) + make_payout( + amount: amount, + destination_account: destination_account, + order_payout_id: order_payout_id + ) + end + + def refresh_status!(payout_id) + payout = Payout.find(payout_id) + return if payout.pending? + + transaction = client.transaction(transaction_id: payout.withdrawal_id) + payout.update_state_by_provider(transaction['status']) + transaction + end + + private + + attr_reader :payout + + def make_payout(amount:, destination_account:, order_payout_id:) + @payout = Payout.create!(amount: amount, destination_account: destination_account, order_payout_id: order_payout_id) + response = client.create_provider_transaction(params: payout_params) + raise Error, "Can't create payout: #{response}" unless response['order_id'] + + payout.pay!(withdrawal_id: response['order_id']) + end + + def payout_params + { + type: 'sell', + amount: payout.amount_cents, + currency: payout.amount_currency.to_s, + method_type: method_type, + customer_id: order.user_id.to_s, + invoice_id: order.public_id.to_s, + sell_details: { + receiver: payout.destination_account, + bank: provider_bank + } + } + end + + def provider_bank + resolver = PaymentServices::Base::P2pBankResolver.new(adapter: self) + sbp_payment? ? resolver.sbp_bank : resolver.card_bank + end + + def method_type + sbp_payment? ? Invoicer::SBP_METHOD_TYPE : Invoicer::CARD_METHOD_TYPE + end + + def sbp_payment? + @sbp_payment ||= order.outcome_unk.present? + end + + def order + @order ||= OrderPayout.find(payout.order_payout_id).order + end + + def client + @client ||= Client.new(api_key: api_key, secret_key: api_secret) + end + end +end diff --git a/payment_services.gemspec b/payment_services.gemspec index 2083d7ad..ac46e398 100644 --- a/payment_services.gemspec +++ b/payment_services.gemspec @@ -38,11 +38,17 @@ Gem::Specification.new do |spec| spec.add_dependency 'activesupport' spec.add_dependency 'jwt' spec.add_dependency 'virtus' - spec.add_dependency 'workflow' + spec.add_dependency 'workflow-activerecord' spec.add_dependency 'block_io' - - spec.add_development_dependency 'bundler', '~> 1.16' - spec.add_development_dependency 'rake', '~> 10.0' - spec.add_development_dependency 'rspec', '~> 3.0' - spec.add_development_dependency 'rubocop', '~> 0.61' + spec.add_dependency 'activerecord' + spec.add_dependency 'money-rails' + spec.add_dependency 'auto_logger', '~> 0.1.4' + + spec.add_development_dependency 'bundler' + spec.add_development_dependency 'rake' + spec.add_development_dependency 'rspec' + spec.add_development_dependency 'rubocop' + spec.add_development_dependency 'sqlite3' + spec.add_development_dependency 'database_cleaner-active_record' + spec.add_development_dependency 'shoulda-matchers' end diff --git a/spec/fixtures/rbk_identities.yml b/spec/fixtures/rbk_identities.yml new file mode 100644 index 00000000..887509a0 --- /dev/null +++ b/spec/fixtures/rbk_identities.yml @@ -0,0 +1,13 @@ +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +identity_1: + rbk_id: identity_123 + current: false + +identity_2: + rbk_id: identity_456 + current: true + +identity_3: + rbk_id: identity_789 + current: false \ No newline at end of file diff --git a/spec/fixtures/rbk_payout_destinations.yml b/spec/fixtures/rbk_payout_destinations.yml new file mode 100644 index 00000000..0ea52cd4 --- /dev/null +++ b/spec/fixtures/rbk_payout_destinations.yml @@ -0,0 +1,34 @@ +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +destination_1: + rbk_identity: identity_1 + rbk_id: dest_456 + public_id: dest_123 + payment_token: token_123 + card_brand: visa + card_bin: '411111' + card_suffix: '1111' + rbk_status: Authorized + payload: {"test": "data"} + +destination_2: + rbk_identity: identity_1 + rbk_id: dest_789 + public_id: dest_456 + payment_token: token_456 + card_brand: mastercard + card_bin: '555555' + card_suffix: '5555' + rbk_status: Pending + payload: {"test": "data2"} + +destination_3: + rbk_identity: identity_2 + rbk_id: dest_999 + public_id: dest_789 + payment_token: token_789 + card_brand: amex + card_bin: '378282' + card_suffix: '8282' + rbk_status: Completed + payload: {"test": "data3"} \ No newline at end of file diff --git a/spec/fixtures/rbk_payouts.yml b/spec/fixtures/rbk_payouts.yml new file mode 100644 index 00000000..d5ec7ffd --- /dev/null +++ b/spec/fixtures/rbk_payouts.yml @@ -0,0 +1,25 @@ +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +payout_1: + rbk_payout_destination: destination_1 + rbk_wallet: wallet_1 + rbk_id: payout_123 + amount_cents: 5000 + rbk_status: processed + payload: {"test": "data"} + +payout_2: + rbk_payout_destination: destination_2 + rbk_wallet: wallet_2 + rbk_id: payout_456 + amount_cents: 10000 + rbk_status: pending + payload: {"test": "data2"} + +payout_3: + rbk_payout_destination: destination_3 + rbk_wallet: wallet_3 + rbk_id: payout_789 + amount_cents: 7500 + rbk_status: completed + payload: {"test": "data3"} \ No newline at end of file diff --git a/spec/fixtures/rbk_wallets.yml b/spec/fixtures/rbk_wallets.yml new file mode 100644 index 00000000..03b24aa9 --- /dev/null +++ b/spec/fixtures/rbk_wallets.yml @@ -0,0 +1,16 @@ +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +wallet_1: + rbk_id: wallet_123 + rbk_identity: identity_1 + current: false + +wallet_2: + rbk_id: wallet_456 + rbk_identity: identity_1 + current: true + +wallet_3: + rbk_id: wallet_789 + rbk_identity: identity_2 + current: false \ No newline at end of file diff --git a/spec/payment_services/base/client_spec.rb b/spec/payment_services/base/client_spec.rb new file mode 100644 index 00000000..81830c6c --- /dev/null +++ b/spec/payment_services/base/client_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require_relative '../../../lib/payment_services/base/client' + +RSpec.describe PaymentServices::Base::Client do + let(:client) { described_class.new } + + describe '#http_request' do + let(:uri) { URI.parse('https://example.com/api') } + let(:http) { double('Net::HTTP') } + let(:request) { double('Request') } + let(:response) { double('Response') } + + before do + allow(client).to receive(:http).with(uri).and_return(http) + allow(client).to receive(:build_request).and_return(request) + allow(request).to receive(:body).and_return('test payload') + allow(http).to receive(:request).and_return(response) + end + + it 'sends HTTP request and returns response' do + result = client.http_request( + url: 'https://example.com/api', + method: :GET, + body: '{"test": "data"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect(result).to eq(response) + end + end + + describe '#build_request' do + let(:uri) { URI.parse('https://example.com/api') } + + context 'when method is POST' do + it 'creates Net::HTTP::Post request' do + request = client.build_request(uri: uri, method: :POST, body: 'test data') + + expect(request).to be_a(Net::HTTP::Post) + expect(request.body).to eq('test data') + end + end + + context 'when method is GET' do + it 'creates Net::HTTP::Get request' do + request = client.build_request(uri: uri, method: :GET) + + expect(request).to be_a(Net::HTTP::Get) + end + end + + context 'when method is PATCH' do + it 'creates Net::HTTP::Patch request' do + request = client.build_request(uri: uri, method: :PATCH, body: 'patch data') + + expect(request).to be_a(Net::HTTP::Patch) + expect(request.body).to eq('patch data') + end + end + + context 'when method is unsupported' do + it 'raises error' do + expect { + client.build_request(uri: uri, method: :DELETE) + }.to raise_error('Запрос DELETE не поддерживается!') + end + end + end + + describe '#safely_parse' do + let(:response) { double('Response') } + + context 'when response body is valid JSON' do + before do + allow(response).to receive(:body).and_return('{"status": "success", "data": [1, 2, 3]}') + end + + it 'parses JSON and returns hash' do + result = client.safely_parse(response) + + expect(result).to eq({ 'status' => 'success', 'data' => [1, 2, 3] }) + end + end + + context 'when response body is invalid JSON' do + before do + allow(response).to receive(:body).and_return('invalid json') + allow(response).to receive(:class).and_return(Net::HTTPBadRequest) + end + + it 'returns raw body and logs warning' do + result = client.safely_parse(response) + + expect(result).to eq('invalid json') + end + end + + context 'when response body is nil' do + before do + allow(response).to receive(:body).and_return(nil) + allow(response).to receive(:class).and_return(Net::HTTPBadRequest) + end + + it 'returns nil body' do + result = client.safely_parse(response) + + expect(result).to be_nil + end + end + end +end \ No newline at end of file diff --git a/spec/payment_services/exmo/transaction_spec.rb b/spec/payment_services/exmo/transaction_spec.rb new file mode 100644 index 00000000..7f932ebd --- /dev/null +++ b/spec/payment_services/exmo/transaction_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative '../../../lib/payment_services/exmo/transaction' + +RSpec.describe PaymentServices::Exmo::Transaction do + describe '.build_from' do + let(:raw_transaction) do + { + 'status' => 'Paid', + 'extra' => { + 'txid' => 'tx_12345' + }, + 'amount' => '100.5', + 'currency' => 'BTC' + } + end + + it 'creates transaction from raw data' do + transaction = described_class.build_from(raw_transaction: raw_transaction) + + expect(transaction.id).to eq('tx_12345') + expect(transaction.provider_state).to eq('Paid') + expect(transaction.source).to eq(raw_transaction) + end + end + + describe '#to_s' do + let(:source) { { 'id' => '123', 'status' => 'Paid' } } + let(:transaction) { described_class.new(source: source) } + + it 'returns source as string' do + expect(transaction.to_s).to eq(source.to_s) + end + end + + describe '#successful?' do + context 'when provider_state is Paid' do + it 'returns true' do + transaction = described_class.new(provider_state: 'Paid') + expect(transaction.successful?).to be true + end + end + + context 'when provider_state is not Paid' do + it 'returns false' do + transaction = described_class.new(provider_state: 'Pending') + expect(transaction.successful?).to be false + end + end + end + + describe '#failed?' do + context 'when provider_state is Cancelled' do + it 'returns true' do + transaction = described_class.new(provider_state: 'Cancelled') + expect(transaction.failed?).to be true + end + end + + context 'when provider_state is Error' do + it 'returns true' do + transaction = described_class.new(provider_state: 'Error') + expect(transaction.failed?).to be true + end + end + + context 'when provider_state is not in failed states' do + it 'returns false' do + transaction = described_class.new(provider_state: 'Paid') + expect(transaction.failed?).to be false + end + end + end +end \ No newline at end of file diff --git a/spec/payment_services/rbk/customer_spec.rb b/spec/payment_services/rbk/customer_spec.rb new file mode 100644 index 00000000..9a6b2f3e --- /dev/null +++ b/spec/payment_services/rbk/customer_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PaymentServices::Rbk::Customer, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:user) } + it { is_expected.to have_many(:payment_cards) } + end + + describe 'scopes' do + let!(:customer1) { described_class.create!(user_id: 1, rbk_id: 'cust_1', access_token: 'token_1') } + let!(:customer2) { described_class.create!(user_id: 2, rbk_id: 'cust_2', access_token: 'token_2') } + + it 'orders customers by id desc' do + expect(described_class.ordered).to eq([customer2, customer1]) + end + end + + describe '#access_token_valid?' do + it 'returns false when access_token_expired_at is nil' do + customer = described_class.new(access_token_expired_at: nil) + expect(customer.access_token_valid?).to be false + end + + it 'returns false when access_token is expired' do + customer = described_class.new(access_token_expired_at: 1.hour.ago) + expect(customer.access_token_valid?).to be false + end + + it 'returns true when access_token is not expired' do + customer = described_class.new(access_token_expired_at: 1.hour.from_now) + expect(customer.access_token_valid?).to be true + end + end +end \ No newline at end of file diff --git a/spec/payment_services/rbk/identity_spec.rb b/spec/payment_services/rbk/identity_spec.rb new file mode 100644 index 00000000..44a07836 --- /dev/null +++ b/spec/payment_services/rbk/identity_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PaymentServices::Rbk::Identity, type: :model do + describe 'associations' do + it { is_expected.to have_many(:rbk_wallets) } + it { is_expected.to have_many(:rbk_payout_destinations) } + end + + describe '.current' do + it 'returns identity marked as current' do + current_identity = described_class.create!(rbk_id: 'id_1', current: true) + described_class.create!(rbk_id: 'id_2', current: false) + + expect(described_class.current).to eq(current_identity) + end + + it 'returns nil when no current identity exists' do + described_class.create!(rbk_id: 'id_1', current: false) + + expect(described_class.current).to be_nil + end + end + + describe '.create_sample!' do + let(:identity_client) { double('IdentityClient') } + let(:response) { { 'id' => 'identity_123', 'metadata' => 'sample' } } + + before do + allow(PaymentServices::Rbk::IdentityClient).to receive(:new).and_return(identity_client) + allow(identity_client).to receive(:create_sample_identity).and_return(response) + end + + it 'creates identity with response data' do + identity = described_class.create_sample! + + expect(identity.rbk_id).to eq('identity_123') + expect(identity.payload).to eq(response) + end + end + + describe '#current_wallet' do + let(:identity) { described_class.new(rbk_id: 'id_1') } + let!(:current_wallet) { double('CurrentWallet') } + let!(:other_wallet) { double('OtherWallet') } + + before do + allow(identity).to receive(:rbk_wallets).and_return(double('Wallets')) + allow(identity.rbk_wallets).to receive(:find_by).with(current: true).and_return(current_wallet) + end + + it 'returns the current wallet' do + expect(identity.current_wallet).to eq(current_wallet) + end + end +end \ No newline at end of file diff --git a/spec/payment_services/rbk/invoice_spec.rb b/spec/payment_services/rbk/invoice_spec.rb new file mode 100644 index 00000000..d97cb3c1 --- /dev/null +++ b/spec/payment_services/rbk/invoice_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PaymentServices::Rbk::Invoice, type: :model do + describe 'associations' do + it { is_expected.to have_many(:payments).dependent(:destroy) } + end + + describe 'scopes' do + let!(:invoice1) { described_class.create!(amount_in_cents: 1000, order_public_id: 1, state: 'pending') } + let!(:invoice2) { described_class.create!(amount_in_cents: 2000, order_public_id: 2, state: 'pending') } + + it 'orders invoices by id desc' do + expect(described_class.ordered).to eq([invoice2, invoice1]) + end + end + + describe 'monetize' do + it 'monetizes amount_in_cents as amount' do + invoice = described_class.new(amount_in_cents: 1500) + expect(invoice.amount.cents).to eq(1500) + expect(invoice.amount.currency.iso_code).to eq('RUB') + end + end + + describe 'workflow states' do + let(:invoice) { described_class.create!(amount_in_cents: 1000, order_public_id: 1, state: 'pending') } + + before do + # Mock fetch_payments! to avoid HTTP calls + allow_any_instance_of(described_class).to receive(:fetch_payments!).and_return([]) + + # Mock order method to avoid external dependencies + order = double('Order') + allow(order).to receive(:auto_confirm!) + allow_any_instance_of(described_class).to receive(:order).and_return(order) + end + + it 'starts in pending state' do + expect(invoice).to be_pending + end + + it 'transitions from pending to paid with pay!' do + expect { invoice.pay! }.to change(invoice, :state).from('pending').to('paid') + end + + it 'transitions from pending to cancelled with cancel!' do + expect { invoice.cancel! }.to change(invoice, :state).from('pending').to('cancelled') + end + end +end \ No newline at end of file diff --git a/spec/payment_services/rbk/payment_card_spec.rb b/spec/payment_services/rbk/payment_card_spec.rb new file mode 100644 index 00000000..9f613bc4 --- /dev/null +++ b/spec/payment_services/rbk/payment_card_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PaymentServices::Rbk::PaymentCard, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:rbk_customer) } + end + + describe 'enums' do + it 'defines card_type enum' do + expect(described_class.card_types).to eq( + 'bank_card' => 0, + 'applepay' => 1, + 'googlepay' => 2 + ) + end + end + + describe '#masked_number' do + it 'formats the card number correctly' do + card = described_class.new(bin: '123456', last_digits: '7890') + expect(card.masked_number).to eq('1234 56** **** 7890') + end + + it 'handles empty bin and last_digits' do + card = described_class.new(bin: '', last_digits: '') + expect(card.masked_number).to eq(' ** **** ') + end + end +end \ No newline at end of file diff --git a/spec/payment_services/rbk/payment_spec.rb b/spec/payment_services/rbk/payment_spec.rb new file mode 100644 index 00000000..ac953d7f --- /dev/null +++ b/spec/payment_services/rbk/payment_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PaymentServices::Rbk::Payment, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:invoice) } + end + + describe 'scopes' do + let!(:payment1) { described_class.create!(amount_in_cents: 1000, rbk_id: 'pay_1', state: 'pending', order_public_id: 1) } + let!(:payment2) { described_class.create!(amount_in_cents: 2000, rbk_id: 'pay_2', state: 'pending', order_public_id: 2) } + + it 'orders payments by id desc' do + expect(described_class.ordered).to eq([payment2, payment1]) + end + end + + describe 'monetize' do + it 'monetizes amount_in_cents as amount' do + payment = described_class.new(amount_in_cents: 1500) + expect(payment.amount.cents).to eq(1500) + expect(payment.amount.currency.iso_code).to eq('RUB') + end + end + + describe 'workflow states' do + let(:invoice) { PaymentServices::Rbk::Invoice.create!(amount_in_cents: 1000, order_public_id: 1, state: 'pending') } + let(:payment) { described_class.create!(amount_in_cents: 1000, rbk_id: 'pay_1', state: 'pending', order_public_id: 1, invoice: invoice) } + + before do + # Mock invoice methods to avoid recursive calls + allow(invoice).to receive(:pay!) + allow(invoice).to receive(:cancel!) + end + + it 'starts in pending state' do + expect(payment).to be_pending + end + + it 'transitions from pending to succeed with success!' do + expect { payment.success! }.to change(payment, :state).from('pending').to('succeed') + end + + it 'transitions from pending to failed with fail!' do + expect { payment.fail! }.to change(payment, :state).from('pending').to('failed') + end + + it 'transitions from pending to refunded with refund!' do + expect { payment.refund! }.to change(payment, :state).from('pending').to('refunded') + end + end + + describe '.rbk_state_to_state' do + let(:payment_client) { double('PaymentClient') } + + before do + allow(PaymentServices::Rbk::PaymentClient).to receive(:const_get).and_return(payment_client) + allow(payment_client).to receive(:const_get).and_return([]) + end + + it 'converts success states to :success' do + allow(PaymentServices::Rbk::PaymentClient).to receive(:const_get).with('SUCCESS_STATES').and_return(['processed']) + expect(described_class.rbk_state_to_state('processed')).to eq(:success) + end + + it 'converts fail states to :fail' do + allow(PaymentServices::Rbk::PaymentClient).to receive(:const_get).with('FAIL_STATES').and_return(['failed']) + expect(described_class.rbk_state_to_state('failed')).to eq(:fail) + end + + it 'raises error for unknown state' do + allow(PaymentServices::Rbk::PaymentClient).to receive(:const_get).and_return([]) + expect { described_class.rbk_state_to_state('unknown') }.to raise_error('Такого статуса не существует: unknown') + end + end + + describe '#payment_tool_info' do + let(:payment) { described_class.new } + let(:payload) do + { + 'payer' => { + 'paymentToolDetails' => { + 'cardNumberMask' => '**** **** **** 1234' + } + } + } + end + + before do + payment.payload = payload + end + + it 'extracts card number mask from payload' do + expect(payment.payment_tool_info).to eq('**** **** **** 1234') + end + + it 'returns nil when payload is empty' do + payment.payload = {} + expect(payment.payment_tool_info).to be_nil + end + end +end \ No newline at end of file diff --git a/spec/payment_services/rbk/payout_destination_spec.rb b/spec/payment_services/rbk/payout_destination_spec.rb new file mode 100644 index 00000000..da7edb4a --- /dev/null +++ b/spec/payment_services/rbk/payout_destination_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PaymentServices::Rbk::PayoutDestination, type: :model do + + describe 'associations' do + it { is_expected.to belong_to(:rbk_identity) } + end + + describe '.find_or_create_from_card_details' do + let(:identity) { PaymentServices::Rbk::Identity.create!(rbk_id: 'identity_123') } + let(:tokenized_card) { { 'token' => 'token_123', 'paymentSystem' => 'visa' } } + + it 'returns existing destination if found' do + existing_destination = described_class.create!( + rbk_identity: identity, + rbk_id: 'dest_456', + payment_token: tokenized_card['token'], + public_id: 'existing_123', + card_brand: 'visa', + card_bin: '411111', + card_suffix: '1111', + rbk_status: 'Authorized', + payload: { 'test' => 'data' } + ) + allow(described_class).to receive(:tokenize_card!).and_return(tokenized_card) + + result = described_class.find_or_create_from_card_details( + number: '4111111111111111', + name: 'John Doe', + exp_date: '12/25', + identity: identity + ) + + expect(result).to eq(existing_destination) + end + + context 'when destination does not exist' do + it 'creates new destination' do + allow(described_class).to receive(:tokenize_card!).and_return(tokenized_card) + new_destination = double('NewDestination') + allow(described_class).to receive(:create_destination!).and_return(new_destination) + + expect(described_class).to receive(:create_destination!).with( + identity: identity, + tokenized_card: tokenized_card + ) + + result = described_class.find_or_create_from_card_details( + number: '4111111111111111', + name: 'John Doe', + exp_date: '12/25', + identity: identity + ) + + expect(result).to eq(new_destination) + end + end + end + + describe '.create_destination!' do + let(:identity) { PaymentServices::Rbk::Identity.create!(rbk_id: 'identity_123') } + let(:tokenized_card) do + { + 'token' => 'token_123', + 'paymentSystem' => 'visa', + 'bin' => '411111', + 'lastDigits' => '1111' + } + end + let(:client) { double('PayoutDestinationClient') } + let(:response) { { 'id' => 'dest_123', 'status' => 'authorized' } } + + before do + allow(PaymentServices::Rbk::PayoutDestinationClient).to receive(:new).and_return(client) + allow(client).to receive(:create_destination).and_return(response) + allow(SecureRandom).to receive(:hex).with(10).and_return('abc123def456') + end + + it 'creates destination with correct attributes' do + destination = described_class.create_destination!( + identity: identity, + tokenized_card: tokenized_card + ) + + expect(destination.rbk_identity).to eq(identity) + expect(destination.rbk_id).to eq('dest_123') + expect(destination.public_id).to eq('abc123def456') + expect(destination.card_brand).to eq('visa') + expect(destination.card_bin).to eq('411111') + expect(destination.card_suffix).to eq('1111') + expect(destination.payment_token).to eq('token_123') + expect(destination.rbk_status).to eq('authorized') + expect(destination.payload).to eq(response) + end + + it 'raises error when response has no id' do + allow(client).to receive(:create_destination).and_return({ 'status' => 'error' }) + + expect { + described_class.create_destination!(identity: identity, tokenized_card: tokenized_card) + }.to raise_error(described_class::Error, 'Rbk failed to create destination: {"status"=>"error"}') + end + end + + describe '.tokenize_card!' do + let(:client) { double('PayoutDestinationClient') } + let(:response) { { 'token' => 'token_123', 'paymentSystem' => 'visa' } } + + before do + allow(PaymentServices::Rbk::PayoutDestinationClient).to receive(:new).and_return(client) + allow(client).to receive(:tokenize_card).and_return(response) + end + + it 'returns tokenized card response' do + result = described_class.tokenize_card!( + number: '4111111111111111', + name: 'John Doe', + exp_date: '12/25' + ) + + expect(result).to eq(response) + end + + it 'raises error when tokenization fails' do + allow(client).to receive(:tokenize_card).and_return({ 'error' => 'invalid card' }) + + expect { + described_class.tokenize_card!( + number: '4111111111111111', + name: 'John Doe', + exp_date: '12/25' + ) + }.to raise_error(described_class::Error, 'Rbk tokenization error: {"error"=>"invalid card"}') + end + end + + describe '#authorized?' do + it 'returns true when status is Authorized' do + destination = described_class.new(rbk_status: 'Authorized') + expect(destination.authorized?).to be true + end + + it 'returns false when status is not Authorized' do + destination = described_class.new(rbk_status: 'Pending') + expect(destination.authorized?).to be false + end + end + + describe '#refresh_info!' do + let(:identity) { PaymentServices::Rbk::Identity.create!(rbk_id: 'identity_123') } + let(:destination) do + described_class.create!( + rbk_identity: identity, + rbk_id: 'dest_123', + public_id: 'dest_123', + payment_token: 'token_123', + card_brand: 'visa', + card_bin: '411111', + card_suffix: '1111', + rbk_status: 'Authorized', + payload: { 'test' => 'data' } + ) + end + let(:client) { double('PayoutDestinationClient') } + let(:response) { { 'id' => 'dest_456', 'status' => 'completed' } } + + before do + allow(PaymentServices::Rbk::PayoutDestinationClient).to receive(:new).and_return(client) + allow(client).to receive(:info).with(destination).and_return(response) + end + + it 'updates destination with response data' do + destination.refresh_info! + + expect(destination.rbk_status).to eq('completed') + expect(destination.payload).to eq(response) + end + + it 'does nothing when response has no status' do + allow(client).to receive(:info).and_return({ 'id' => 'dest_123' }) + + expect { destination.refresh_info! }.not_to change(destination, :rbk_status) + end + end +end \ No newline at end of file diff --git a/spec/payment_services/rbk/payout_spec.rb b/spec/payment_services/rbk/payout_spec.rb new file mode 100644 index 00000000..3aa376d3 --- /dev/null +++ b/spec/payment_services/rbk/payout_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PaymentServices::Rbk::Payout, type: :model do + + describe 'associations' do + it { is_expected.to belong_to(:rbk_payout_destination) } + it { is_expected.to belong_to(:rbk_wallet) } + end + + describe '.create_from!' do + let(:identity) { PaymentServices::Rbk::Identity.create!(rbk_id: 'identity_123') } + let(:destination) do + PaymentServices::Rbk::PayoutDestination.create!( + rbk_identity: identity, + rbk_id: 'dest_456', + public_id: 'dest_123', + payment_token: 'token_123', + card_brand: 'visa', + card_bin: '411111', + card_suffix: '1111', + rbk_status: 'Authorized', + payload: { 'test' => 'data' } + ) + end + let(:wallet) { PaymentServices::Rbk::Wallet.create!(rbk_identity: identity, rbk_id: 'wallet_123') } + let(:payout_client) { double('PayoutClient') } + let(:response) { { 'id' => 'payout_123', 'status' => 'processed' } } + + before do + allow(PaymentServices::Rbk::PayoutClient).to receive(:new).and_return(payout_client) + allow(payout_client).to receive(:make_payout).and_return(response) + end + + it 'creates payout with response data' do + payout = described_class.create_from!( + destination: destination, + wallet: wallet, + amount_cents: 5000 + ) + + expect(payout.rbk_id).to eq('payout_123') + expect(payout.rbk_payout_destination).to eq(destination) + expect(payout.rbk_wallet).to eq(wallet) + expect(payout.amount_cents).to eq(5000) + expect(payout.payload).to eq(response) + expect(payout.rbk_status).to eq('processed') + end + + it 'raises error when response status is missing' do + allow(payout_client).to receive(:make_payout).and_return({ 'id' => 'payout_123' }) + + expect { + described_class.create_from!( + destination: destination, + wallet: wallet, + amount_cents: 5000 + ) + }.to raise_error(described_class::Error, 'Rbk payout error: {"id"=>"payout_123"}') + end + end + + describe '#refresh_info!' do + let(:identity) { PaymentServices::Rbk::Identity.create!(rbk_id: 'identity_123') } + let(:destination) do + PaymentServices::Rbk::PayoutDestination.create!( + rbk_identity: identity, + rbk_id: 'dest_456', + public_id: 'dest_123', + payment_token: 'token_123', + card_brand: 'visa', + card_bin: '411111', + card_suffix: '1111', + rbk_status: 'Authorized', + payload: { 'test' => 'data' } + ) + end + let(:wallet) { PaymentServices::Rbk::Wallet.create!(rbk_identity: identity, rbk_id: 'wallet_123') } + let(:payout) do + described_class.create!( + rbk_id: 'payout_123', + rbk_payout_destination: destination, + rbk_wallet: wallet, + amount_cents: 5000, + rbk_status: 'processed', + payload: { 'test' => 'data' } + ) + end + let(:payout_client) { double('PayoutClient') } + let(:response) { { 'id' => 'payout_123', 'status' => 'completed' } } + + before do + allow(PaymentServices::Rbk::PayoutClient).to receive(:new).and_return(payout_client) + allow(payout_client).to receive(:info).with(payout).and_return(response) + end + + it 'updates payout with response data' do + payout.refresh_info! + + expect(payout.rbk_status).to eq('completed') + expect(payout.payload).to eq(response) + end + + it 'does nothing when response is empty' do + allow(payout_client).to receive(:info).and_return(nil) + + expect { payout.refresh_info! }.not_to change(payout, :rbk_status) + end + + it 'does nothing when response has no status' do + allow(payout_client).to receive(:info).and_return({ 'id' => 'payout_123' }) + + expect { payout.refresh_info! }.not_to change(payout, :rbk_status) + end + end +end \ No newline at end of file diff --git a/spec/payment_services/rbk/wallet_spec.rb b/spec/payment_services/rbk/wallet_spec.rb new file mode 100644 index 00000000..3e14f555 --- /dev/null +++ b/spec/payment_services/rbk/wallet_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe PaymentServices::Rbk::Wallet, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:rbk_identity) } + end + + describe '.create_for_identity' do + let(:identity) { double('Identity', id: 1, rbk_wallets: double('wallets')) } + let(:wallet_client) { double('WalletClient') } + let(:response) { { 'id' => 'wallet_123', 'name' => 'Test Wallet' } } + + before do + allow(PaymentServices::Rbk::WalletClient).to receive(:new).and_return(wallet_client) + allow(wallet_client).to receive(:create_wallet).with(identity: identity).and_return(response) + end + + it 'creates a wallet with response data' do + expect(identity.rbk_wallets).to receive(:create!).with( + rbk_id: 'wallet_123', + payload: response + ) + + described_class.create_for_identity(identity) + end + end +end \ No newline at end of file diff --git a/spec/payment_services/tronscan/transaction_spec.rb b/spec/payment_services/tronscan/transaction_spec.rb new file mode 100644 index 00000000..ba7c6d9f --- /dev/null +++ b/spec/payment_services/tronscan/transaction_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require_relative '../../../lib/payment_services/tronscan/transaction' + +RSpec.describe PaymentServices::Tronscan::Transaction do + describe '.build_from' do + let(:raw_transaction) do + { + id: 'tx_12345', + created_at: Time.new(2023, 1, 1, 12, 0, 0), + source: { + 'amount' => '100.5', + 'currency' => 'TRX', + 'confirmed' => true + } + } + end + + it 'creates transaction from raw data' do + transaction = described_class.build_from(raw_transaction: raw_transaction) + + expect(transaction.id).to eq('tx_12345') + expect(transaction.created_at).to eq(Time.new(2023, 1, 1, 12, 0, 0)) + expect(transaction.source).to eq( + { + amount: '100.5', + currency: 'TRX', + confirmed: true + } + ) + end + + it 'symbolizes keys in source hash' do + transaction = described_class.build_from(raw_transaction: raw_transaction) + expect(transaction.source.keys).to all(be_a(Symbol)) + end + end + + describe '#to_s' do + let(:source) { { id: '123', amount: '100', confirmed: true } } + let(:transaction) { described_class.new(source: source) } + + it 'returns source as string' do + expect(transaction.to_s).to eq(source.to_s) + end + end + + describe '#successful?' do + context 'when source confirmed is true' do + it 'returns true' do + transaction = described_class.new(source: { confirmed: true }) + expect(transaction.successful?).to be true + end + end + + context 'when source confirmed is false' do + it 'returns false' do + transaction = described_class.new(source: { confirmed: false }) + expect(transaction.successful?).to be false + end + end + + context 'when source is empty' do + it 'returns false' do + transaction = described_class.new(source: {}) + expect(transaction.successful?).to be false + end + end + + context 'when source is nil' do + it 'returns false' do + transaction = described_class.new(source: nil) + expect(transaction.successful?).to be false + end + end + end +end \ No newline at end of file diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 00000000..730615ca --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Copyright (c) 2018 FINFEX https://github.com/finfex + +require 'spec_helper' + +# Configure ActiveRecord for testing +ENV['RAILS_ENV'] ||= 'test' + +# Load ActiveRecord and establish connection +require 'active_record' +require 'yaml' +require 'database_cleaner-active_record' +require 'active_support/time' +require 'active_record/fixtures' +require 'pathname' + +# Configure time zone +Time.zone = 'UTC' + +# Define Rails.root for fixtures +module Rails + def self.root + @root ||= Pathname.new(File.join(__dir__, '..')) + end +end + +db_config = YAML.load_file(File.join(__dir__, '..', 'config', 'database.yml')) +ActiveRecord::Base.establish_connection(db_config['test']) + +# Create tables needed for tests +require_relative 'support/schema' +require_relative 'support/models' + + +# Configure RSpec for Rails/ActiveRecord +RSpec.configure do |config| + # Clean up database before each test + config.before(:suite) do + DatabaseCleaner.strategy = :transaction + DatabaseCleaner.clean_with(:truncation) + end + + config.around(:each) do |example| + DatabaseCleaner.cleaning do + example.run + end + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 61f595dd..0f4f42ef 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,9 +6,19 @@ Bundler.require(:development, :test) require 'active_support/core_ext/module' require 'virtus' +require 'database_cleaner-active_record' require 'payment_services' +# Load shoulda-matchers +require 'shoulda/matchers' +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :active_record + end +end + RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = '.rspec_status' diff --git a/spec/support/models.rb b/spec/support/models.rb new file mode 100644 index 00000000..ab6356f7 --- /dev/null +++ b/spec/support/models.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Define minimal models needed for testing +class User < ActiveRecord::Base + # Minimal user model for association testing +end \ No newline at end of file diff --git a/spec/support/schema.rb b/spec/support/schema.rb new file mode 100644 index 00000000..a9fa1e5a --- /dev/null +++ b/spec/support/schema.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +# Create database tables needed for testing +ActiveRecord::Schema.define do + # User table for associations + create_table :users, force: true do |t| + t.integer "group_id", default: 1, null: false + t.string "login", limit: 100 + t.string "nickname", limit: 100, default: "", null: false + t.string "password", limit: 60, null: false + t.string "email", null: false + t.string "phone", limit: 100 + t.string "icq", limit: 15, default: "", null: false + t.datetime "regdate", null: false + t.datetime "logdate", null: false + t.date "birthdate", null: false + t.boolean "is_locked", default: false, null: false + t.integer "is_locked2", limit: 1, default: 0, null: false + t.boolean "is_logged_once", default: false, null: false + t.integer "rating", default: 0, null: false + t.integer "points", default: 0, null: false + t.string "last_ip", limit: 15, default: "", null: false + t.string "status", default: "", null: false + t.datetime "status_date" + t.integer "invited_by" + t.datetime "invdate" + t.timestamps + end + + # RBK Money tables + create_table :rbk_money_customers, force: true do |t| + t.string "rbk_id", null: false + t.integer "user_id", null: false + t.json "payload" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "access_token", null: false + t.datetime "access_token_expired_at" + t.index ["user_id"], name: "index_rbk_money_customers_on_user_id" + end + + create_table :rbk_payment_cards, force: true do |t| + t.string "rbk_id", null: false + t.string "bin", null: false + t.string "last_digits", null: false + t.string "rbk_customer_id", null: false + t.string "brand", null: false + t.integer "card_type", default: 0, null: false + t.json "payload", null: false + t.index ["rbk_customer_id"], name: "index_rbk_payment_cards_on_rbk_customer_id" + end + + create_table :rbk_identities, force: true do |t| + t.string "rbk_id", null: false + t.json "payload" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "current", default: false, null: false + end + + create_table :rbk_wallets, force: true do |t| + t.string "rbk_id", null: false + t.bigint "rbk_identity_id", null: false + t.json "payload" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "current", default: false, null: false + t.index ["rbk_identity_id"], name: "fk_rails_b1e89ecd83" + end + + create_table :rbk_money_invoices, force: true do |t| + t.integer "amount_in_cents", null: false + t.string "rbk_invoice_id" + t.string "description" + t.string "state" + t.json "payload" + t.bigint "order_public_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.text "access_token" + t.index ["order_public_id"], name: "index_rbk_money_invoices_on_order_public_id", unique: true + end + + create_table :rbk_money_payments, force: true do |t| + t.string "rbk_id", null: false + t.string "state", null: false + t.integer "amount_in_cents", null: false + t.json "payload" + t.json "refund_payload" + t.bigint "order_public_id", null: false + t.bigint "rbk_money_invoice_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["order_public_id"], name: "index_rbk_money_payments_on_order_public_id", unique: true + t.index ["rbk_money_invoice_id"], name: "index_rbk_money_payments_on_rbk_money_invoice_id" + end + + create_table :rbk_payouts, force: true do |t| + t.bigint "rbk_payout_destination_id", null: false + t.bigint "rbk_wallet_id", null: false + t.integer "amount_cents", null: false + t.json "payload" + t.string "rbk_status", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "rbk_id", null: false + t.index ["rbk_payout_destination_id"], name: "fk_rails_eb1172faa0" + t.index ["rbk_wallet_id"], name: "fk_rails_4be66d8283" + end + + create_table :rbk_payout_destinations, force: true do |t| + t.bigint "rbk_identity_id", null: false + t.string "rbk_id", null: false + t.string "public_id", null: false + t.string "card_brand", null: false + t.string "card_bin", null: false + t.string "card_suffix", null: false + t.string "payment_token", null: false + t.string "rbk_status", null: false + t.json "payload", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["rbk_identity_id"], name: "fk_rails_d31b9211b9" + end +end \ No newline at end of file diff --git a/spec/unit/base_client_spec.rb b/spec/unit/base_client_spec.rb new file mode 100644 index 00000000..ebf078eb --- /dev/null +++ b/spec/unit/base_client_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require 'net/http' +require 'uri' +require 'json' + +# Mock the base client class for testing +class TestBaseClient + TIMEOUT = 30 + + def http_request(url:, method:, body: nil, headers: nil) + uri = URI.parse(url) + https = http(uri) + request = build_request(uri: uri, method: method, body: body, headers: headers) + https.request(request) + end + + def build_request(uri:, method:, body: nil, headers: nil) + request = if method == :POST + Net::HTTP::Post.new(uri.request_uri, headers) + elsif method == :GET + Net::HTTP::Get.new(uri.request_uri, headers) + elsif method == :PATCH + Net::HTTP::Patch.new(uri.request_uri, headers) + else + raise "Запрос #{method} не поддерживается!" + end + request.body = body + request + end + + def http(uri) + Net::HTTP.start(uri.host, uri.port, + use_ssl: true, + verify_mode: OpenSSL::SSL::VERIFY_NONE, + open_timeout: TIMEOUT, + read_timeout: TIMEOUT) + end + + def safely_parse(response) + res = JSON.parse(response.body) + res + rescue JSON::ParserError, TypeError => err + response.body + end +end + +RSpec.describe TestBaseClient do + let(:client) { described_class.new } + + describe '#build_request' do + let(:uri) { URI.parse('https://example.com/api') } + + context 'when method is POST' do + it 'creates Net::HTTP::Post request' do + request = client.build_request(uri: uri, method: :POST, body: 'test data') + + expect(request).to be_a(Net::HTTP::Post) + expect(request.body).to eq('test data') + end + end + + context 'when method is GET' do + it 'creates Net::HTTP::Get request' do + request = client.build_request(uri: uri, method: :GET) + + expect(request).to be_a(Net::HTTP::Get) + end + end + + context 'when method is PATCH' do + it 'creates Net::HTTP::Patch request' do + request = client.build_request(uri: uri, method: :PATCH, body: 'patch data') + + expect(request).to be_a(Net::HTTP::Patch) + expect(request.body).to eq('patch data') + end + end + + context 'when method is unsupported' do + it 'raises error' do + expect { + client.build_request(uri: uri, method: :DELETE) + }.to raise_error('Запрос DELETE не поддерживается!') + end + end + end + + describe '#safely_parse' do + let(:response) { double('Response') } + + context 'when response body is valid JSON' do + before do + allow(response).to receive(:body).and_return('{"status": "success", "data": [1, 2, 3]}') + end + + it 'parses JSON and returns hash' do + result = client.safely_parse(response) + + expect(result).to eq({ 'status' => 'success', 'data' => [1, 2, 3] }) + end + end + + context 'when response body is invalid JSON' do + before do + allow(response).to receive(:body).and_return('invalid json') + end + + it 'returns raw body' do + result = client.safely_parse(response) + + expect(result).to eq('invalid json') + end + end + + context 'when response body is nil' do + before do + allow(response).to receive(:body).and_return(nil) + end + + it 'returns nil body' do + result = client.safely_parse(response) + + expect(result).to be_nil + end + end + end +end \ No newline at end of file diff --git a/spec/unit/exmo_transaction_spec.rb b/spec/unit/exmo_transaction_spec.rb new file mode 100644 index 00000000..646b3de0 --- /dev/null +++ b/spec/unit/exmo_transaction_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +# Minimal test for Exmo Transaction model without loading full application +require 'virtus' + +# Mock the transaction class for testing +class TestExmoTransaction + include Virtus.model + + SUCCESSFULL_PROVIDER_STATE = 'Paid' + FAILED_PROVIDER_STATES = %w(Cancelled Error) + + attribute :id, String + attribute :provider_state, Integer + attribute :source, String + + def self.build_from(raw_transaction:) + new( + id: raw_transaction['extra']['txid'], + provider_state: raw_transaction['status'], + source: raw_transaction + ) + end + + def to_s + source.to_s + end + + def successful? + provider_state == SUCCESSFULL_PROVIDER_STATE + end + + def failed? + FAILED_PROVIDER_STATES.include?(provider_state) + end +end + +RSpec.describe TestExmoTransaction do + describe '.build_from' do + let(:raw_transaction) do + { + 'status' => 'Paid', + 'extra' => { + 'txid' => 'tx_12345' + }, + 'amount' => '100.5', + 'currency' => 'BTC' + } + end + + it 'creates transaction from raw data' do + transaction = described_class.build_from(raw_transaction: raw_transaction) + + expect(transaction.id).to eq('tx_12345') + expect(transaction.provider_state).to eq('Paid') + expect(transaction.source).to eq(raw_transaction) + end + end + + describe '#to_s' do + let(:source) { { 'id' => '123', 'status' => 'Paid' } } + let(:transaction) { described_class.new(source: source) } + + it 'returns source as string' do + expect(transaction.to_s).to eq(source.to_s) + end + end + + describe '#successful?' do + context 'when provider_state is Paid' do + it 'returns true' do + transaction = described_class.new(provider_state: 'Paid') + expect(transaction.successful?).to be true + end + end + + context 'when provider_state is not Paid' do + it 'returns false' do + transaction = described_class.new(provider_state: 'Pending') + expect(transaction.successful?).to be false + end + end + end + + describe '#failed?' do + context 'when provider_state is Cancelled' do + it 'returns true' do + transaction = described_class.new(provider_state: 'Cancelled') + expect(transaction.failed?).to be true + end + end + + context 'when provider_state is Error' do + it 'returns true' do + transaction = described_class.new(provider_state: 'Error') + expect(transaction.failed?).to be true + end + end + + context 'when provider_state is not in failed states' do + it 'returns false' do + transaction = described_class.new(provider_state: 'Paid') + expect(transaction.failed?).to be false + end + end + end +end \ No newline at end of file diff --git a/spec/unit/tronscan_transaction_spec.rb b/spec/unit/tronscan_transaction_spec.rb new file mode 100644 index 00000000..cbe95e5b --- /dev/null +++ b/spec/unit/tronscan_transaction_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +# Minimal test for Tronscan Transaction model without loading full application +require 'virtus' + +# Mock the transaction class for testing +class TestTronscanTransaction + include Virtus.model + + attribute :id, String + attribute :created_at, DateTime + attribute :source, Hash + + def self.build_from(raw_transaction:) + new( + id: raw_transaction[:id], + created_at: raw_transaction[:created_at], + source: raw_transaction[:source].deep_symbolize_keys + ) + end + + def to_s + source.to_s + end + + def successful? + source&.dig(:confirmed) || false + end +end + +# Add deep_symbolize_keys method for hash +class Hash + def deep_symbolize_keys + each_with_object({}) do |(key, value), hash| + hash[key.to_sym] = value.is_a?(Hash) ? value.deep_symbolize_keys : value + end + end +end + +RSpec.describe TestTronscanTransaction do + describe '.build_from' do + let(:raw_transaction) do + { + id: 'tx_12345', + created_at: Time.new(2023, 1, 1, 12, 0, 0), + source: { + 'amount' => '100.5', + 'currency' => 'TRX', + 'confirmed' => true + } + } + end + + it 'creates transaction from raw data' do + transaction = described_class.build_from(raw_transaction: raw_transaction) + + expect(transaction.id).to eq('tx_12345') + expect(transaction.created_at).to eq(Time.new(2023, 1, 1, 12, 0, 0)) + expect(transaction.source).to eq( + { + amount: '100.5', + currency: 'TRX', + confirmed: true + } + ) + end + + it 'symbolizes keys in source hash' do + transaction = described_class.build_from(raw_transaction: raw_transaction) + expect(transaction.source.keys).to all(be_a(Symbol)) + end + end + + describe '#to_s' do + let(:source) { { id: '123', amount: '100', confirmed: true } } + let(:transaction) { described_class.new(source: source) } + + it 'returns source as string' do + expect(transaction.to_s).to eq(source.to_s) + end + end + + describe '#successful?' do + context 'when source confirmed is true' do + it 'returns true' do + transaction = described_class.new(source: { confirmed: true }) + expect(transaction.successful?).to be true + end + end + + context 'when source confirmed is false' do + it 'returns false' do + transaction = described_class.new(source: { confirmed: false }) + expect(transaction.successful?).to be false + end + end + + context 'when source is empty' do + it 'returns false' do + transaction = described_class.new(source: {}) + expect(transaction.successful?).to be false + end + end + + context 'when source is nil' do + it 'returns false' do + transaction = described_class.new(source: nil) + expect(transaction.successful?).to be false + end + end + end +end \ No newline at end of file