From d0bf0a74c86155fff07af55e2267bc2c67af2fd5 Mon Sep 17 00:00:00 2001 From: Ahmed Shahwan Date: Tue, 14 Jan 2025 02:09:52 +0200 Subject: [PATCH] feat: moneyhash provider feat(moneyhash): generate graphql schema for moneyhash fix(moneyhash) customer creation fix(moneyhash): webhook feat(moneyhash): update required fields for connection creation/update feat(moneyhash): build webhook_end_point based on LAGO_API_URL feat(moneyhash): Use flow_id if exists fix(moneyhash): correct amount before sending it feat(moneyhash): remove redirect_url cleanup: remove commented code feat(moneyhash): make flow_id required during connection creation feat(moneyhash): Only use flow_id instead of operation feat(moneyhash): add more to custom_fields feat(moneyhash): Implement moneyhash/payments/create_service fix(moneyhash): get agreement_id from subscription_external_id fix(payment_providers): possible types regenerate graphql schema fix formatting according to rubocop revert unrelated change shorten Types::Customers::Object#provider_customer better refactor Types::Customers::Object#provider_customer feat(moneyhash): put-back-success-url-field Expected by code shared between providers feat(moneyhash): revert a refactor, disable rubocop rule cleanup: remove unncessary newline remove potential duplicate email calls Send email when can't create a payment, too Fix payment_url for one-off invoices Remove expire_after_seconds for invoice payments Revamp PaymentProviders::Moneyhash::Payments::CreateService --- app/controllers/webhooks_controller.rb | 18 + .../payment_providers/moneyhash/base.rb | 20 + .../payment_providers/moneyhash/create.rb | 18 + .../payment_providers/moneyhash/update.rb | 18 + .../resolvers/payment_providers_resolver.rb | 2 + app/graphql/types/customers/object.rb | 4 +- app/graphql/types/mutation_type.rb | 2 + .../types/payment_providers/moneyhash.rb | 22 + .../payment_providers/moneyhash_input.rb | 15 + app/graphql/types/payment_providers/object.rb | 5 +- .../types/payment_providers/update_input.rb | 1 + .../invoices/payments/moneyhash_create_job.rb | 18 + .../moneyhash_checkout_url_job.rb | 14 + .../moneyhash_create_job.rb | 15 + .../moneyhash/handle_event_job.rb | 14 + .../payments/moneyhash_create_job.rb | 16 + app/models/customer.rb | 5 +- .../moneyhash_customer.rb | 33 ++ .../payment_providers/moneyhash_provider.rb | 61 +++ app/serializers/v1/customer_serializer.rb | 3 + .../dunning_campaigns/update_service.rb | 2 + .../invoices/payments/moneyhash_service.rb | 211 ++++++++++ .../payments/payment_providers/factory.rb | 2 + .../payment_provider_customers/factory.rb | 2 + .../moneyhash_service.rb | 178 ++++++++ .../create_customer_factory.rb | 2 + .../create_payment_factory.rb | 2 + .../moneyhash/customers/create_service.rb | 79 ++++ .../handle_incoming_webhook_service.rb | 40 ++ .../moneyhash/payments/create_service.rb | 106 +++++ .../payment_providers/moneyhash_service.rb | 161 +++++++ .../payments/moneyhash_service.rb | 223 ++++++++++ .../payments/payment_providers/factory.rb | 2 + config/routes.rb | 1 + schema.graphql | 67 ++- schema.json | 398 ++++++++++++++++++ 36 files changed, 1776 insertions(+), 4 deletions(-) create mode 100644 app/graphql/mutations/payment_providers/moneyhash/base.rb create mode 100644 app/graphql/mutations/payment_providers/moneyhash/create.rb create mode 100644 app/graphql/mutations/payment_providers/moneyhash/update.rb create mode 100644 app/graphql/types/payment_providers/moneyhash.rb create mode 100644 app/graphql/types/payment_providers/moneyhash_input.rb create mode 100644 app/jobs/invoices/payments/moneyhash_create_job.rb create mode 100644 app/jobs/payment_provider_customers/moneyhash_checkout_url_job.rb create mode 100644 app/jobs/payment_provider_customers/moneyhash_create_job.rb create mode 100644 app/jobs/payment_providers/moneyhash/handle_event_job.rb create mode 100644 app/jobs/payment_requests/payments/moneyhash_create_job.rb create mode 100644 app/models/payment_provider_customers/moneyhash_customer.rb create mode 100644 app/models/payment_providers/moneyhash_provider.rb create mode 100644 app/services/invoices/payments/moneyhash_service.rb create mode 100644 app/services/payment_provider_customers/moneyhash_service.rb create mode 100644 app/services/payment_providers/moneyhash/customers/create_service.rb create mode 100644 app/services/payment_providers/moneyhash/handle_incoming_webhook_service.rb create mode 100644 app/services/payment_providers/moneyhash/payments/create_service.rb create mode 100644 app/services/payment_providers/moneyhash_service.rb create mode 100644 app/services/payment_requests/payments/moneyhash_service.rb diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 5cf97b4a52c..9219726d87a 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -74,4 +74,22 @@ def adyen def adyen_params params["notificationItems"]&.first&.dig("NotificationRequestItem")&.permit! end + + def moneyhash + result = PaymentProviders::Moneyhash::HandleIncomingWebhookService.call( + organization_id: params[:organization_id], + code: params[:code].presence, + body: JSON.parse(request.body.read) + ) + + unless result.success? + if result.error.is_a?(BaseService::ServiceFailure) && result.error.code == "webhook_error" + return head(:bad_request) + end + + result.raise_if_error! + end + + head(:ok) + end end diff --git a/app/graphql/mutations/payment_providers/moneyhash/base.rb b/app/graphql/mutations/payment_providers/moneyhash/base.rb new file mode 100644 index 00000000000..3c17f977b08 --- /dev/null +++ b/app/graphql/mutations/payment_providers/moneyhash/base.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Moneyhash + class Base < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + def resolve(**args) + result = ::PaymentProviders::MoneyhashService + .new(context[:current_user]) + .create_or_update(**args.merge(organization: current_organization)) + + result.success? ? result.moneyhash_provider : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/moneyhash/create.rb b/app/graphql/mutations/payment_providers/moneyhash/create.rb new file mode 100644 index 00000000000..b46f04ead89 --- /dev/null +++ b/app/graphql/mutations/payment_providers/moneyhash/create.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Moneyhash + class Create < Base + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "AddMoneyhashPaymentProvider" + description "Add Moneyhash payment provider" + + input_object_class Types::PaymentProviders::MoneyhashInput + + type Types::PaymentProviders::Moneyhash + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/moneyhash/update.rb b/app/graphql/mutations/payment_providers/moneyhash/update.rb new file mode 100644 index 00000000000..4f23d768b38 --- /dev/null +++ b/app/graphql/mutations/payment_providers/moneyhash/update.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Moneyhash + class Update < Base + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateMoneyhashPaymentProvider" + description "Update Moneyhash payment provider" + + input_object_class Types::PaymentProviders::UpdateInput + + type Types::PaymentProviders::Moneyhash + end + end + end +end diff --git a/app/graphql/resolvers/payment_providers_resolver.rb b/app/graphql/resolvers/payment_providers_resolver.rb index c54d309b469..d914560bac7 100644 --- a/app/graphql/resolvers/payment_providers_resolver.rb +++ b/app/graphql/resolvers/payment_providers_resolver.rb @@ -33,6 +33,8 @@ def provider_type(type) PaymentProviders::GocardlessProvider.to_s when "cashfree" PaymentProviders::CashfreeProvider.to_s + when "moneyhash" + PaymentProviders::MoneyhashProvider.to_s else raise(NotImplementedError) end diff --git a/app/graphql/types/customers/object.rb b/app/graphql/types/customers/object.rb index ae4ae58e992..76f430f2d84 100644 --- a/app/graphql/types/customers/object.rb +++ b/app/graphql/types/customers/object.rb @@ -125,7 +125,7 @@ def active_subscriptions_count object.active_subscriptions.count end - def provider_customer + def provider_customer # rubocop:disable GraphQL/ResolverMethodLength case object&.payment_provider&.to_sym when :stripe object.stripe_customer @@ -135,6 +135,8 @@ def provider_customer object.cashfree_customer when :adyen object.adyen_customer + when :moneyhash + object.moneyhash_customer end end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 7cabdddffa3..134131f1af4 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -50,11 +50,13 @@ class MutationType < Types::BaseObject field :add_adyen_payment_provider, mutation: Mutations::PaymentProviders::Adyen::Create field :add_cashfree_payment_provider, mutation: Mutations::PaymentProviders::Cashfree::Create field :add_gocardless_payment_provider, mutation: Mutations::PaymentProviders::Gocardless::Create + field :add_moneyhash_payment_provider, mutation: Mutations::PaymentProviders::Moneyhash::Create field :add_stripe_payment_provider, mutation: Mutations::PaymentProviders::Stripe::Create field :update_adyen_payment_provider, mutation: Mutations::PaymentProviders::Adyen::Update field :update_cashfree_payment_provider, mutation: Mutations::PaymentProviders::Cashfree::Update field :update_gocardless_payment_provider, mutation: Mutations::PaymentProviders::Gocardless::Update + field :update_moneyhash_payment_provider, mutation: Mutations::PaymentProviders::Moneyhash::Update field :update_stripe_payment_provider, mutation: Mutations::PaymentProviders::Stripe::Update field :destroy_payment_provider, mutation: Mutations::PaymentProviders::Destroy diff --git a/app/graphql/types/payment_providers/moneyhash.rb b/app/graphql/types/payment_providers/moneyhash.rb new file mode 100644 index 00000000000..833657525ba --- /dev/null +++ b/app/graphql/types/payment_providers/moneyhash.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class Moneyhash < Types::BaseObject + graphql_name "MoneyhashProvider" + + field :api_key, String, null: true, permission: "organization:integrations:view" + field :code, String, null: false + field :flow_id, String, null: true, permission: "organization:integrations:view" + field :id, ID, null: false + field :name, String, null: false + field :success_redirect_url, String, null: true, permission: "organization:integrations:view" + + # NOTE: Api key is a sensitive information. It should not be sent back to the + # front end application. Instead we send an obfuscated value + def api_key + "#{"•" * 8}…#{object.api_key[-3..]}" + end + end + end +end diff --git a/app/graphql/types/payment_providers/moneyhash_input.rb b/app/graphql/types/payment_providers/moneyhash_input.rb new file mode 100644 index 00000000000..a0600c29327 --- /dev/null +++ b/app/graphql/types/payment_providers/moneyhash_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class MoneyhashInput < BaseInputObject + description "Moneyhash input arguments" + + argument :api_key, String, required: true + argument :code, String, required: true + argument :flow_id, String, required: true + argument :name, String, required: true + argument :success_redirect_url, String, required: false + end + end +end diff --git a/app/graphql/types/payment_providers/object.rb b/app/graphql/types/payment_providers/object.rb index d189432e3ec..d2555138098 100644 --- a/app/graphql/types/payment_providers/object.rb +++ b/app/graphql/types/payment_providers/object.rb @@ -8,7 +8,8 @@ class Object < Types::BaseUnion possible_types Types::PaymentProviders::Adyen, Types::PaymentProviders::Gocardless, Types::PaymentProviders::Stripe, - Types::PaymentProviders::Cashfree + Types::PaymentProviders::Cashfree, + Types::PaymentProviders::Moneyhash def self.resolve_type(object, _context) case object.class.to_s @@ -20,6 +21,8 @@ def self.resolve_type(object, _context) Types::PaymentProviders::Gocardless when "PaymentProviders::CashfreeProvider" Types::PaymentProviders::Cashfree + when "PaymentProviders::MoneyhashProvider" + Types::PaymentProviders::Moneyhash else raise "Unexpected Payment provider type: #{object.inspect}" end diff --git a/app/graphql/types/payment_providers/update_input.rb b/app/graphql/types/payment_providers/update_input.rb index 776d586e1eb..3bd47f525fb 100644 --- a/app/graphql/types/payment_providers/update_input.rb +++ b/app/graphql/types/payment_providers/update_input.rb @@ -6,6 +6,7 @@ class UpdateInput < BaseInputObject description "Update input arguments" argument :code, String, required: false + argument :flow_id, String, required: false argument :id, ID, required: true argument :name, String, required: false argument :success_redirect_url, String, required: false diff --git a/app/jobs/invoices/payments/moneyhash_create_job.rb b/app/jobs/invoices/payments/moneyhash_create_job.rb new file mode 100644 index 00000000000..97dab622905 --- /dev/null +++ b/app/jobs/invoices/payments/moneyhash_create_job.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class MoneyhashCreateJob < ApplicationJob + queue_as "providers" + + unique :until_executed + + retry_on Faraday::ConnectionFailed, wait: :polynomially_longer, attempts: 6 + + def perform(invoice) + result = Invoices::Payments::MoneyhashService.new(invoice).create + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/payment_provider_customers/moneyhash_checkout_url_job.rb b/app/jobs/payment_provider_customers/moneyhash_checkout_url_job.rb new file mode 100644 index 00000000000..d472de50c65 --- /dev/null +++ b/app/jobs/payment_provider_customers/moneyhash_checkout_url_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class MoneyhashCheckoutUrlJob < ApplicationJob + queue_as :providers + + retry_on ActiveJob::DeserializationError + + def perform(moneyhash_customer) + result = PaymentProviderCustomers::MoneyhashService.new(moneyhash_customer).generate_checkout_url + result.raise_if_error! + end + end +end diff --git a/app/jobs/payment_provider_customers/moneyhash_create_job.rb b/app/jobs/payment_provider_customers/moneyhash_create_job.rb new file mode 100644 index 00000000000..8506d4a276c --- /dev/null +++ b/app/jobs/payment_provider_customers/moneyhash_create_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class MoneyhashCreateJob < ApplicationJob + queue_as :providers + + retry_on ActiveJob::DeserializationError + + def perform(moneyhash_customer) + result = PaymentProviderCustomers::MoneyhashService.new(moneyhash_customer).create + + result.raise_if_error! + end + end +end diff --git a/app/jobs/payment_providers/moneyhash/handle_event_job.rb b/app/jobs/payment_providers/moneyhash/handle_event_job.rb new file mode 100644 index 00000000000..42613972513 --- /dev/null +++ b/app/jobs/payment_providers/moneyhash/handle_event_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module PaymentProviders + module Moneyhash + class HandleEventJob < ApplicationJob + queue_as "providers" + + def perform(organization:, event_json:) + result = ::PaymentProviders::MoneyhashService.new.handle_event(organization:, event_json:) + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/payment_requests/payments/moneyhash_create_job.rb b/app/jobs/payment_requests/payments/moneyhash_create_job.rb new file mode 100644 index 00000000000..eda1277b840 --- /dev/null +++ b/app/jobs/payment_requests/payments/moneyhash_create_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class MoneyhashCreateJob < ApplicationJob + queue_as "providers" + + unique :until_executed + + def perform(payable) + result = PaymentRequests::Payments::MoneyhashService.new(payable).create + result.raise_if_error! + end + end + end +end diff --git a/app/models/customer.rb b/app/models/customer.rb index 7dd6421cd03..abf20fdd46a 100644 --- a/app/models/customer.rb +++ b/app/models/customer.rb @@ -74,8 +74,9 @@ class Customer < ApplicationRecord has_one :xero_customer, class_name: "IntegrationCustomers::XeroCustomer" has_one :hubspot_customer, class_name: "IntegrationCustomers::HubspotCustomer" has_one :salesforce_customer, class_name: "IntegrationCustomers::SalesforceCustomer" + has_one :moneyhash_customer, class_name: "PaymentProviderCustomers::MoneyhashCustomer" - PAYMENT_PROVIDERS = %w[stripe gocardless cashfree adyen].freeze + PAYMENT_PROVIDERS = %w[stripe gocardless cashfree adyen moneyhash].freeze default_scope -> { kept } sequenced scope: ->(customer) { customer.organization.customers.with_discarded }, @@ -169,6 +170,8 @@ def provider_customer cashfree_customer when :adyen adyen_customer + when :moneyhash + moneyhash_customer end end diff --git a/app/models/payment_provider_customers/moneyhash_customer.rb b/app/models/payment_provider_customers/moneyhash_customer.rb new file mode 100644 index 00000000000..02c74a9d027 --- /dev/null +++ b/app/models/payment_provider_customers/moneyhash_customer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class MoneyhashCustomer < BaseCustomer + settings_accessors :payment_method_id + end +end + +# == Schema Information +# +# Table name: payment_provider_customers +# +# id :uuid not null, primary key +# deleted_at :datetime +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# payment_provider_id :uuid +# provider_customer_id :string +# +# Indexes +# +# index_payment_provider_customers_on_customer_id_and_type (customer_id,type) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_provider_customers_on_payment_provider_id (payment_provider_id) +# index_payment_provider_customers_on_provider_customer_id (provider_customer_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/payment_providers/moneyhash_provider.rb b/app/models/payment_providers/moneyhash_provider.rb new file mode 100644 index 00000000000..6b745bd0b21 --- /dev/null +++ b/app/models/payment_providers/moneyhash_provider.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module PaymentProviders + class MoneyhashProvider < BaseProvider + SUCCESS_REDIRECT_URL = "https://moneyhash.io/" + + validates :api_key, presence: true + validates :flow_id, url: true, presence: true, length: {maximum: 20} + + secrets_accessors :api_key + settings_accessors :flow_id + + def self.api_base_url + if Rails.env.production? + "https://web.moneyhash.io" + else + "https://staging-web.moneyhash.io" + end + end + + def webhook_end_point + URI.join( + ENV["LAGO_API_URL"], + "webhooks/moneyhash/#{organization_id}?code=#{URI.encode_www_form_component(code)}" + ) + end + + def environment + if Rails.env.production? && live_prefix.present? + :live + else + :test + end + end + end +end + +# == Schema Information +# +# Table name: payment_providers +# +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_providers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/serializers/v1/customer_serializer.rb b/app/serializers/v1/customer_serializer.rb index d7fbfeba55b..6f157c0f0d0 100644 --- a/app/serializers/v1/customer_serializer.rb +++ b/app/serializers/v1/customer_serializer.rb @@ -80,6 +80,9 @@ def billing_configuration when :adyen configuration[:provider_customer_id] = model.adyen_customer&.provider_customer_id configuration.merge!(model.adyen_customer&.settings&.symbolize_keys || {}) + when :moneyhash + configuration[:provider_customer_id] = model.moneyhash_customer&.provider_customer_id + configuration.merge!(model.moneyhash_customer&.settings&.symbolize_keys || {}) end configuration diff --git a/app/services/dunning_campaigns/update_service.rb b/app/services/dunning_campaigns/update_service.rb index 84b568b0b13..c8fed6d73b8 100644 --- a/app/services/dunning_campaigns/update_service.rb +++ b/app/services/dunning_campaigns/update_service.rb @@ -37,6 +37,8 @@ def permitted_attributes end def handle_thresholds + thresholds_updated = false + input_threshold_ids = params[:thresholds].map { |t| t[:id] }.compact # Delete thresholds not included in the payload diff --git a/app/services/invoices/payments/moneyhash_service.rb b/app/services/invoices/payments/moneyhash_service.rb new file mode 100644 index 00000000000..0f47e4d9f72 --- /dev/null +++ b/app/services/invoices/payments/moneyhash_service.rb @@ -0,0 +1,211 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class MoneyhashService < BaseService + include Customers::PaymentProviderFinder + + PENDING_STATUSES = %w[processing].freeze + SUCCESS_STATUSES = %w[succeeded].freeze + FAILED_STATUSES = %w[failed].freeze + + def initialize(invoice = nil) + @invoice = invoice + + super(nil) + end + + def create + result.invoice = invoice + return result unless should_process_payment? + + unless invoice.total_amount_cents.positive? + update_invoice_payment_status(payment_status: :succeeded) + return result + end + + increment_payment_attempts + + payment = Payment.new( + payable: invoice, + payment_provider_id: moneyhash_payment_provider.id, + payment_provider_customer_id: customer.moneyhash_customer.id, + amount_cents: invoice.total_amount_cents, + amount_currency: invoice.currency.upcase, + provider_payment_id: provider_payment_id, + status: status + ) + payment.save! + + invoice_payment_status = invoice_payment_status(payment.status) + update_invoice_payment_status(payment_status: invoice_payment_status) + + Integrations::Aggregator::Payments::CreateJob.perform_later(payment:) if payment.should_sync_payment? + + result.payment = payment + result + end + + def update_payment_status(organization_id:, provider_payment_id:, status:, metadata: {}) + payment_obj = Payment.find_or_initialize_by(provider_payment_id: provider_payment_id) + payment = if payment_obj.persisted? + payment_obj + else + create_payment(provider_payment_id:, metadata:) + end + + return handle_missing_payment(organization_id, metadata) unless payment + result.payment = payment + result.invoice = payment.payable + return result if payment.payable.payment_succeeded? + payment.update!(status:) + update_invoice_payment_status(payment_status: invoice_payment_status(status), processing: status == "processing") + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + def generate_payment_url + return result unless should_process_payment? + response = client.post_with_response(payment_url_params, headers) + moneyhash_result = JSON.parse(response.body) + + return result unless moneyhash_result + + moneyhash_result_data = moneyhash_result["data"] + result.payment_url = moneyhash_result_data["embed_url"] + result + rescue LagoHttpClient::HttpError => e + deliver_error_webhook(e) + result.service_failure!(code: e.error_code, message: e.message) + end + + private + + attr_accessor :invoice + + delegate :organization, :customer, to: :invoice + + def handle_missing_payment(organization_id, metadata) + return result unless metadata&.key?("lago_payable_id") + invoice = Invoice.find_by(id: metadata["lago_payable_id"], organization_id:) + return result if invoice.nil? + + return result if invoice.payment_failed? + + result.not_found_failure!(resource: "moneyhash_payment") + end + + def update_invoice_payment_status(payment_status:, deliver_webhook: true, processing: false) + result = Invoices::UpdateService.call( + invoice: invoice.presence || @result.invoice, + params: { + payment_status:, + ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded + }, + webhook_notification: deliver_webhook + ) + result.raise_if_error! + end + + def increment_payment_attempts + invoice.update!(payment_attempts: invoice.payment_attempts + 1) + end + + def create_payment(provider_payment_id:, metadata:) + @invoice = Invoice.find_by(id: metadata["lago_payable_id"]) + unless @invoice + result.not_found_failure!(resource: "invoice") + return + end + increment_payment_attempts + Payment.new( + payable: invoice, + payment_provider_id: moneyhash_payment_provider.id, + payment_provider_customer_id: customer.moneyhash_customer.id, + amount_cents: invoice.total_amount_cents, + amount_currency: invoice.currency&.upcase, + provider_payment_id: + ) + end + + def should_process_payment? + return false if invoice.payment_succeeded? || invoice.voided? + return false if moneyhash_payment_provider.blank? + + customer&.moneyhash_customer&.provider_customer_id + end + + def client + @client || LagoHttpClient::Client.new("#{::PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/") + end + + def headers + { + "Content-Type" => "application/json", + "x-Api-Key" => moneyhash_payment_provider.api_key + } + end + + def moneyhash_payment_provider + @moneyhash_payment_provider ||= payment_provider(customer) + end + + def invoice_payment_status(payment_status) + return :pending if PENDING_STATUSES.include?(payment_status) + return :succeeded if SUCCESS_STATUSES.include?(payment_status) + return :failed if FAILED_STATUSES.include?(payment_status) + + payment_status + end + + def payment_url_params + params = { + amount: invoice.total_amount_cents / 100.0, + amount_currency: invoice.currency.upcase, + flow_id: moneyhash_payment_provider.flow_id, + billing_data: { + first_name: invoice&.customer&.firstname, + last_name: invoice&.customer&.lastname, + phone_number: invoice&.customer&.phone, + email: invoice&.customer&.email + }, + customer: invoice.customer.moneyhash_customer.provider_customer_id, + webhook_url: moneyhash_payment_provider.webhook_end_point, + merchant_initiated: false, + tokenize_card: true, + custom_fields: { + lago_mit: false, + lago_customer_id: invoice&.customer&.id, + lago_payable_id: invoice.id, + lago_payable_type: invoice.class.name, + lago_organization_id: organization&.id, + lago_mh_service: "Invoices::Payments::MoneyhashService" + } + } + # Include recurring data for subscription invoices only + if invoice.invoice_type == "subscription" + params[:recurring_data] = { + agreement_id: invoice.subscriptions&.first&.external_id + } + params[:payment_type] = "UNSCHEDULED" + params[:custom_fields].merge!( + lago_plan_id: invoice.subscriptions&.first&.plan_id, + lago_subscription_external_id: invoice.subscriptions&.first&.external_id + ) + end + params + end + + def deliver_error_webhook(moneyhash_error) + DeliverErrorWebhookService.call_async(invoice, { + provider_customer_id: customer.moneyhash_customer.provider_customer_id, + provider_error: { + message: moneyhash_error.message, + error_code: moneyhash_error.error_code + } + }) + end + end + end +end diff --git a/app/services/invoices/payments/payment_providers/factory.rb b/app/services/invoices/payments/payment_providers/factory.rb index 1db33989c35..879f7eb5ccb 100644 --- a/app/services/invoices/payments/payment_providers/factory.rb +++ b/app/services/invoices/payments/payment_providers/factory.rb @@ -18,6 +18,8 @@ def self.service_class(payment_provider) Invoices::Payments::GocardlessService when "cashfree" Invoices::Payments::CashfreeService + when "moneyhash" + Invoices::Payments::MoneyhashService else raise(NotImplementedError) end diff --git a/app/services/payment_provider_customers/factory.rb b/app/services/payment_provider_customers/factory.rb index d155bd4b008..efe37f5cc80 100644 --- a/app/services/payment_provider_customers/factory.rb +++ b/app/services/payment_provider_customers/factory.rb @@ -16,6 +16,8 @@ def self.service_class(provider_customer) PaymentProviderCustomers::CashfreeService when "PaymentProviderCustomers::AdyenCustomer" PaymentProviderCustomers::AdyenService + when "PaymentProviderCustomers::MoneyhashCustomer" + PaymentProviderCustomers::MoneyhashService else raise(NotImplementedError) end diff --git a/app/services/payment_provider_customers/moneyhash_service.rb b/app/services/payment_provider_customers/moneyhash_service.rb new file mode 100644 index 00000000000..c0922da0ce4 --- /dev/null +++ b/app/services/payment_provider_customers/moneyhash_service.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class MoneyhashService < BaseService + include Customers::PaymentProviderFinder + + def initialize(moneyhash_customer = nil) + @moneyhash_customer = moneyhash_customer + + super(nil) + end + + def create + result.moneyhash_customer = moneyhash_customer + return result if moneyhash_customer.provider_customer_id? + moneyhash_result = create_moneyhash_customer + + return result if !result.success? + + provider_customer_id = begin + moneyhash_result["data"]["id"] + rescue + "" + end + + moneyhash_customer.update!( + provider_customer_id: provider_customer_id + ) + deliver_success_webhook + result.moneyhash_customer = moneyhash_customer + checkout_url_result = generate_checkout_url + return result unless checkout_url_result.success? + result.checkout_url = checkout_url_result.checkout_url + result + end + + def update + result + end + + def generate_checkout_url(send_webhook: true) + return result.not_found_failure!(resource: "moneyhash_payment_provider") unless moneyhash_payment_provider + + response = payment_url_client.post_with_response(payment_url_params, headers) + moneyhash_result = JSON.parse(response.body) + + return result unless moneyhash_result + + moneyhash_result_data = moneyhash_result["data"] + result.checkout_url = moneyhash_result_data["embed_url"] + + if send_webhook + SendWebhookJob.perform_now( + "customer.checkout_url_generated", + customer, + checkout_url: result.checkout_url + ) + end + result + rescue LagoHttpClient::HttpError => e + deliver_error_webhook(e) + result.service_failure!(code: e.error_code, message: e.message) + end + + def update_payment_method(organization_id:, customer_id:, payment_method_id:, metadata: {}) + customer = PaymentProviderCustomers::MoneyhashCustomer.find_by(customer_id: customer_id) + return handle_missing_customer(organization_id, metadata) unless customer + + customer.payment_method_id = payment_method_id + customer.save! + + result.moneyhash_customer = customer + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :moneyhash_customer + + delegate :customer, to: :moneyhash_customer + + def client + @client || LagoHttpClient::Client.new("#{PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/customers/") + end + + def payment_url_client + @payment_url_client || LagoHttpClient::Client.new("#{::PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/") + end + + def api_key + moneyhash_payment_provider.secret_key + end + + def moneyhash_payment_provider + @moneyhash_payment_provider ||= payment_provider(customer) + end + + def create_moneyhash_customer + customer_params = { + type: customer&.customer_type&.upcase, + first_name: customer&.firstname, + last_name: customer&.lastname, + email: customer&.email, + phone_number: customer&.phone, + tax_id: customer&.tax_identification_number, + address: customer&.address_line1, + contact_person_name: customer&.display_name, + company_name: customer&.legal_name + }.compact + + response = client.post_with_response(customer_params, headers) + JSON.parse(response.body) + rescue LagoHttpClient::HttpError => e + deliver_error_webhook(e) + nil + end + + def deliver_error_webhook(moneyhash_error) + SendWebhookJob.perform_later( + "customer.payment_provider_error", + customer, + provider_error: { + message: moneyhash_error.message, + error_code: moneyhash_error.error_code + } + ) + end + + def deliver_success_webhook + SendWebhookJob.perform_later( + "customer.payment_provider_created", + customer + ) + end + + def headers + { + "Content-Type" => "application/json", + "x-Api-Key" => moneyhash_payment_provider.api_key + } + end + + def payment_url_params + { + amount: 5, + amount_currency: customer.currency.presence || "USD", + flow_id: moneyhash_payment_provider.flow_id, + expires_after_seconds: 69.days.seconds.to_i, + billing_data: { + first_name: customer&.firstname, + last_name: customer&.lastname, + phone_number: customer&.phone, + email: customer&.email + }, + customer: moneyhash_customer.provider_customer_id, + webhook_url: moneyhash_payment_provider.webhook_end_point, + merchant_initiated: false, + tokenize_card: true, + payment_type: "UNSCHEDULED", + custom_fields: { + lago_mit: false, + lago_customer_id: moneyhash_customer.customer_id, + lago_organization_id: moneyhash_customer&.customer&.organization&.id, + lago_mh_service: "PaymentProviderCustomers::MoneyhashService" + } + } + end + + def handle_missing_customer(organization_id, metadata) + return result unless metadata&.key?("lago_customer_id") + return result if Customer.find_by(id: metadata["lago_customer_id"], organization_id:).nil? + + result.not_found_failure!(resource: "moneyhash_customer") + end + end +end diff --git a/app/services/payment_providers/create_customer_factory.rb b/app/services/payment_providers/create_customer_factory.rb index b9ac4f1cfb6..7a7a9fc52a2 100644 --- a/app/services/payment_providers/create_customer_factory.rb +++ b/app/services/payment_providers/create_customer_factory.rb @@ -16,6 +16,8 @@ def self.service_class(provider:) PaymentProviders::Gocardless::Customers::CreateService when "stripe" PaymentProviders::Stripe::Customers::CreateService + when "moneyhash" + PaymentProviders::Moneyhash::Customers::CreateService end end end diff --git a/app/services/payment_providers/create_payment_factory.rb b/app/services/payment_providers/create_payment_factory.rb index 5a9af3da14a..a0eb19c7c53 100644 --- a/app/services/payment_providers/create_payment_factory.rb +++ b/app/services/payment_providers/create_payment_factory.rb @@ -18,6 +18,8 @@ def self.service_class(provider:) PaymentProviders::Gocardless::Payments::CreateService when :stripe PaymentProviders::Stripe::Payments::CreateService + when :moneyhash + PaymentProviders::Moneyhash::Payments::CreateService end end end diff --git a/app/services/payment_providers/moneyhash/customers/create_service.rb b/app/services/payment_providers/moneyhash/customers/create_service.rb new file mode 100644 index 00000000000..4b91ef43eef --- /dev/null +++ b/app/services/payment_providers/moneyhash/customers/create_service.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module PaymentProviders + module Moneyhash + module Customers + class CreateService < BaseService + def initialize(customer:, payment_provider_id:, params:, async: true) + @customer = customer + @payment_provider_id = payment_provider_id + @params = params || {} + @async = async + + super + end + + def call + provider_customer = PaymentProviderCustomers::MoneyhashCustomer.find_by(customer_id: customer.id) + provider_customer ||= PaymentProviderCustomers::MoneyhashCustomer.new(customer_id: customer.id, payment_provider_id:) + + if params.key?(:provider_customer_id) + provider_customer.provider_customer_id = params[:provider_customer_id].presence + end + + if params.key?(:sync_with_provider) + provider_customer.sync_with_provider = params[:sync_with_provider].presence + end + + provider_customer.save! + + result.provider_customer = provider_customer + + if should_create_provider_customer? + create_customer_on_provider_service(async) + elsif should_generate_checkout_url? + generate_checkout_url(async) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :customer, :payment_provider_id, :params, :async + + delegate :organization, to: :customer + + def create_customer_on_provider_service(async) + return PaymentProviderCustomers::MoneyhashCreateJob.perform_later(result.provider_customer) if async + + PaymentProviderCustomers::MoneyhashCreateJob.perform_now(result.provider_customer) + end + + def generate_checkout_url(async) + return PaymentProviderCustomers::MoneyhashCheckoutUrlJob.perform_later(result.provider_customer) if async + + PaymentProviderCustomers::MoneyhashCheckoutUrlJob.perform_now(result.provider_customer) + end + + def should_create_provider_customer? + # NOTE: the customer does not exists on the service provider + # and the customer id was not removed from the customer + # customer sync with provider setting is set to true + !result.provider_customer.provider_customer_id? && + !result.provider_customer.provider_customer_id_previously_changed? && + result.provider_customer.sync_with_provider.present? + end + + def should_generate_checkout_url? + !result.provider_customer.id_previously_changed?(from: nil) && # it was not created but updated + result.provider_customer.provider_customer_id_previously_changed? && + result.provider_customer.provider_customer_id? && + result.provider_customer.sync_with_provider.blank? + end + end + end + end +end diff --git a/app/services/payment_providers/moneyhash/handle_incoming_webhook_service.rb b/app/services/payment_providers/moneyhash/handle_incoming_webhook_service.rb new file mode 100644 index 00000000000..296b9cf8ce3 --- /dev/null +++ b/app/services/payment_providers/moneyhash/handle_incoming_webhook_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module PaymentProviders + module Moneyhash + class HandleIncomingWebhookService < BaseService + def initialize(organization_id:, body:, code: nil) + @organization_id = organization_id + @body = body + @code = code + super + end + + def call + organization = Organization.find_by(id: organization_id) + return result.service_failure!(code: "webhook_error", message: "Organization not found") unless organization + + payment_provider_result = PaymentProviders::FindService.call( + organization_id:, + code:, + payment_provider_type: "moneyhash" + ) + + return handle_payment_provider_failure(payment_provider_result) unless payment_provider_result.success? + + PaymentProviders::Moneyhash::HandleEventJob.perform_later(organization:, event_json: body) + result.event = body + result + end + + private + + attr_reader :organization_id, :body, :code + + def handle_payment_provider_failure(payment_provider_result) + return payment_provider_result unless payment_provider_result.error.is_a?(BaseService::ServiceFailure) + result.service_failure!(code: "webhook_error", message: payment_provider_result.error.error_message) + end + end + end +end diff --git a/app/services/payment_providers/moneyhash/payments/create_service.rb b/app/services/payment_providers/moneyhash/payments/create_service.rb new file mode 100644 index 00000000000..34c58ac816f --- /dev/null +++ b/app/services/payment_providers/moneyhash/payments/create_service.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module PaymentProviders + module Moneyhash + module Payments + class CreateService < BaseService + include ::Customers::PaymentProviderFinder + + def initialize(payment:) + @payment = payment + @invoice = payment.payable + @provider_customer = payment.payment_provider_customer + + super + end + + def call + result.payment = payment + + if @invoice.invoice_type == "subscription" + + moneyhash_result = create_moneyhash_payment + + payment.provider_payment_id = moneyhash_result.dig("data", "id") + payment.status = moneyhash_result.dig("data", "status") + payment.payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.save! + + result.payment = payment + + else + result.fail_with_error!("Moneyhash supports automatic payments only for subscription invoices.") + end + + result + end + + private + + attr_reader :payment, :invoice, :provider_customer + + delegate :payment_provider, :customer, to: :provider_customer + + def create_moneyhash_payment + payment_params = { + amount: invoice.total_amount_cents / 100.0, + amount_currency: invoice.currency.upcase, + flow_id: moneyhash_payment_provider.flow_id, + billing_data: { + first_name: invoice&.customer&.firstname, + last_name: invoice&.customer&.lastname, + phone_number: invoice&.customer&.phone, + email: invoice&.customer&.email + }, + customer: provider_customer.provider_customer_id, + webhook_url: moneyhash_payment_provider.webhook_end_point, + merchant_initiated: true, + recurring_data: { + agreement_id: invoice.subscriptions&.first&.external_id + }, + custom_fields: { + lago_mit: true, + lago_customer_id: invoice&.customer&.id, + lago_payable_id: invoice.id, + lago_payable_type: invoice.class.name, + lago_plan_id: invoice.subscriptions&.first&.plan_id, + lago_subscription_external_id: invoice.subscriptions&.first&.external_id, + lago_organization_id: organization&.id, + lago_mh_service: "PaymentProviders::Moneyhash::Payments::CreateService" + } + } + + response = client.post_with_response(payment_params, headers) + JSON.parse(response.body) + rescue LagoHttpClient::HttpError => e + prepare_failed_result(e, reraise: true) + end + + def client + @client || LagoHttpClient::Client.new("#{::PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/") + end + + def headers + { + "Content-Type" => "application/json", + "x-Api-Key" => moneyhash_payment_provider.api_key + } + end + + def moneyhash_payment_provider + @moneyhash_payment_provider ||= payment_provider(customer) + end + + def prepare_failed_result(error, reraise: false) + result.error_message = error.message + result.error_code = error.code + result.reraise = reraise + + payment.update!(status: :failed, payable_payment_status: :failed) + + result.service_failure!(code: "moneyhash_error", message: "#{error.code}: #{error.message}") + end + end + end + end +end diff --git a/app/services/payment_providers/moneyhash_service.rb b/app/services/payment_providers/moneyhash_service.rb new file mode 100644 index 00000000000..f37ae70ac65 --- /dev/null +++ b/app/services/payment_providers/moneyhash_service.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +module PaymentProviders + class MoneyhashService < BaseService + PENDING_STATUSES = %w[PENDING].freeze + SUCCESS_STATUSES = %w[PROCESSED].freeze + FAILED_STATUSES = %w[FAILED].freeze + + INTENT_WEBHOOKS_EVENTS = %w[intent.processed intent.time_expired].freeze + TRANSACTION_WEBHOOKS_EVENTS = %w[transaction.purchase.failed transaction.purchase.pending transaction.purchase.successful].freeze + CARD_WEBHOOKS_EVENTS = %w[card_token.created card_token.updated card_token.deleted].freeze + + ALLOWED_WEBHOOK_EVENTS = (INTENT_WEBHOOKS_EVENTS + TRANSACTION_WEBHOOKS_EVENTS + CARD_WEBHOOKS_EVENTS).freeze + + PAYMENT_SERVICE_CLASS_MAP = { + "Invoice" => Invoices::Payments::MoneyhashService, + "PaymentRequest" => PaymentRequests::Payments::MoneyhashService + }.freeze + + def create_or_update(**args) + payment_provider_result = PaymentProviders::FindService.call( + organization_id: args[:organization].id, + code: args[:code], + id: args[:id], + payment_provider_type: "moneyhash" + ) + + moneyhash_provider = if payment_provider_result.success? + payment_provider_result.payment_provider + else + PaymentProviders::MoneyhashProvider.new( + organization_id: args[:organization].id, + code: args[:code] + ) + end + + moneyhash_provider.api_key + old_code = moneyhash_provider.code + moneyhash_provider.api_key = args[:api_key] if args.key?(:api_key) + moneyhash_provider.code = args[:code] if args.key?(:code) + moneyhash_provider.name = args[:name] if args.key?(:name) + moneyhash_provider.flow_id = args[:flow_id] if args.key?(:flow_id) + + moneyhash_provider.save(validate: false) + + if payment_provider_code_changed?(moneyhash_provider, old_code, args) + moneyhash_provider.customers.update_all(payment_provider_code: args[:code]) # rubocop:disable Rails/SkipsModelValidations + end + + result.moneyhash_provider = moneyhash_provider + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + def handle_event(organization:, event_json:) + @event_json = event_json + @event_code = event_json["type"] + @organization = organization + + unless ALLOWED_WEBHOOK_EVENTS.include?(@event_code) + return result.service_failure!( + code: "webhook_error", + message: "Invalid moneyhash event code: #{@event_code}" + ) + end + event_handlers.fetch(@event_code, method(:default_handler)).call + end + + private + + def event_handlers + { + "intent.time_expired" => method(:handle_intent_event), + "transaction.purchase.failed" => method(:handle_transaction_event), + "transaction.purchase.pending" => method(:handle_transaction_event), + "transaction.purchase.successful" => method(:handle_transaction_event), + "card_token.created" => method(:handle_card_event), + "card_token.updated" => method(:handle_card_event), + "card_token.deleted" => method(:handle_card_event) + } + end + + def handle_intent_event + payment_statuses = { + "intent.time_expired": "failed" + } + case @event_code + when "intent.time_expired" + payment_service_klass(@event_json) + .new.update_payment_status( + organization_id: @organization.id, + provider_payment_id: @event_json.dig("data", "intent_id"), + status: payment_statuses[@event_code.to_sym], + metadata: @event_json.dig("data", "intent", "custom_fields") + ).raise_if_error! + end + end + + def handle_transaction_event + payment_statuses = { + "transaction.purchase.failed": "failed", + "transaction.purchase.pending": "processing", + "transaction.purchase.successful": "succeeded" + } + case @event_code + when "transaction.purchase.failed", "transaction.purchase.pending", "transaction.purchase.successful" + payment_service_klass(@event_json) + .new.update_payment_status( + organization_id: @organization.id, + provider_payment_id: @event_json.dig("intent", "id"), + status: payment_statuses[@event_code.to_sym], + metadata: @event_json.dig("intent", "custom_fields") + ).raise_if_error! + end + end + + def handle_card_event + service = PaymentProviderCustomers::MoneyhashService.new + + case @event_code + when "card_token.deleted" + payment_method_id = @event_json.dig("data", "card_token", "id") + customer_id = @event_json.dig("data", "card_token", "custom_fields", "lago_customer_id") + customer = PaymentProviderCustomers::MoneyhashCustomer.find_by(customer_id: customer_id) + + selected_payment_method_id = (customer&.payment_method_id == payment_method_id) ? nil : payment_method_id + service + .update_payment_method( + organization_id: @organization.id, + customer_id: customer_id, + payment_method_id: selected_payment_method_id, + metadata: @event_json.dig("data", "card_token", "custom_fields") + ).raise_if_error! + + when "card_token.created", "card_token.updated" + service + .update_payment_method( + organization_id: @organization.id, + customer_id: @event_json.dig("data", "card_token", "custom_fields", "lago_customer_id"), + payment_method_id: @event_json.dig("data", "card_token", "id"), + metadata: @event_json.dig("data", "card_token", "custom_fields") + ).raise_if_error! + end + end + + def payment_service_klass(event_json) + payable_type = event_json.dig("intent", "custom_fields", "lago_payable_type") || "Invoice" + PAYMENT_SERVICE_CLASS_MAP.fetch(payable_type) do + raise NameError, "Invalid lago_payable_type: #{payable_type}" + end + end + + def default_handler + result.service_failure!( + code: "webhook_error", + message: "No handler for event code: #{@event_code}" + ) + end + end +end diff --git a/app/services/payment_requests/payments/moneyhash_service.rb b/app/services/payment_requests/payments/moneyhash_service.rb new file mode 100644 index 00000000000..5df00d571db --- /dev/null +++ b/app/services/payment_requests/payments/moneyhash_service.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class MoneyhashService < BaseService + include Customers::PaymentProviderFinder + + PENDING_STATUSES = %w[PENDING].freeze + SUCCESS_STATUSES = %w[PROCESSED].freeze + FAILED_STATUSES = %w[FAILED].freeze + + def initialize(payable = nil) + @payable = payable + + super(nil) + end + + def create + result.payable = payable + result.single_validation_failure!(error_code: "payment_method_error") if moneyhash_payment_method.nil? + return result unless should_process_payment? + + unless payable.total_amount_cents.positive? + update_payable_payment_status(payment_status: :succeeded) + return result + end + + payable.increment_payment_attempts! + + moneyhash_result = create_moneyhash_payment + return result unless moneyhash_result + + payment = Payment.new( + payable: payable, + payment_provider_id: moneyhash_payment_provider.id, + payment_provider_customer_id: customer.moneyhash_customer.id, + amount_cents: payable.amount_cents, + amount_currency: payable.currency&.upcase, + provider_payment_id: moneyhash_result.dig("data", "id"), + status: moneyhash_result.dig("data", "status") + ) + + payment.save! + + payable_payment_status = payable_payment_status(payment.status) + + update_payable_payment_status( + payment_status: payable_payment_status, + processing: payment.status == "processing" + ) + update_invoices_payment_status( + payment_status: payable_payment_status, + processing: payment.status == "processing" + ) + result.payment = payment + result.payable_payment_status + result + end + + def update_payment_status(organization_id:, provider_payment_id:, status:, metadata: {}) + payment_obj = Payment.find_or_initialize_by(provider_payment_id: provider_payment_id) + payment = if payment_obj.persisted? + payment_obj + else + create_payment(provider_payment_id:, metadata:) + end + + return handle_missing_payment(organization_id, metadata) unless payment + + result.payment = payment + result.payable = payment.payable + return result if payment.payable.payment_succeeded? + payment.update!(status:) + + processing = status == "processing" + payment_status = payable_payment_status(status) + update_payable_payment_status(payment_status:, processing:) + update_invoices_payment_status(payment_status:, processing:) + + PaymentRequestMailer.with(payment_request: payment.payable).requested.deliver_later if result.payable.payment_failed? + result + rescue BaseService::FailedResult => e + PaymentRequestMailer.with(payment_request: payment.payable).requested.deliver_later if result.payable.payment_failed? + result.fail_with_error!(e) + end + + private + + attr_accessor :payable + + delegate :organization, :customer, to: :payable + + def handle_missing_payment(organization_id, metadata) + return result unless metadata&.key?("lago_payable_id") + payment_request = PaymentRequest.find_by(id: metadata["lago_payable_id"], organization_id:) + return result unless payment_request + return result if payment_request.payment_failed? + + result.not_found_failure!(resource: "moneyhash_payment") + end + + def create_payment(provider_payment_id:, metadata:) + @payable = PaymentRequest.find_by(id: metadata["lago_payable_id"]) + + unless payable + result.not_found_failure!(resource: "payment_request") + return + end + + payable.increment_payment_attempts! + + Payment.new( + payable:, + payment_provider_id: moneyhash_payment_provider.id, + payment_provider_customer_id: customer.moneyhash_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency&.upcase, + provider_payment_id: + ) + end + + def moneyhash_payment_method + customer.moneyhash_customer.payment_method_id + end + + def should_process_payment? + return false if payable.payment_succeeded? + return false if moneyhash_payment_provider.blank? + + !!customer&.moneyhash_customer&.provider_customer_id + end + + def client + @client || LagoHttpClient::Client.new("#{::PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/") + end + + def headers + { + "Content-Type" => "application/json", + "x-Api-Key" => moneyhash_payment_provider.api_key + } + end + + def moneyhash_payment_provider + @moneyhash_payment_provider ||= payment_provider(customer) + end + + def create_moneyhash_payment + payment_params = { + amount: payable.total_amount_cents / 100.0, + amount_currency: payable.currency.upcase, + flow_id: moneyhash_payment_provider.flow_id, + customer: customer.moneyhash_customer.provider_customer_id, + webhook_url: moneyhash_payment_provider.webhook_end_point, + merchant_initiated: true, + payment_type: "UNSCHEDULED", + card_token: moneyhash_payment_method, + recurring_data: { + agreement_id: payable&.invoices&.first&.subscriptions&.first&.external_id + }, + custom_fields: { + lago_mit: true, + lago_customer_id: customer&.id, + lago_payable_id: payable.id, + lago_payable_type: payable.class.name, + lago_organization_id: organization&.id, + lago_plan_id: payable&.invoices&.first&.subscriptions&.first&.plan_id, + lago_subscription_external_id: payable&.invoices&.first&.subscriptions&.first&.external_id, + lago_mh_service: "PaymentRequests::Payments::MoneyhashService" + } + } + response = client.post_with_response(payment_params, headers) + JSON.parse(response.body) + rescue LagoHttpClient::HttpError => e + deliver_error_webhook(e) + update_payable_payment_status(payment_status: :failed, deliver_webhook: false) + nil + end + + def payable_payment_status(payment_status) + return :pending if PENDING_STATUSES.include?(payment_status) + return :succeeded if SUCCESS_STATUSES.include?(payment_status) + return :failed if FAILED_STATUSES.include?(payment_status) + + payment_status + end + + def update_payable_payment_status(payment_status:, deliver_webhook: true, processing: false) + UpdateService.call( + payable: result.payable, + params: { + payment_status:, + ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + + def update_invoices_payment_status(payment_status:, deliver_webhook: true, processing: false) + result.payable.invoices.each do |invoice| + Invoices::UpdateService.call( + invoice: invoice, + params: { + payment_status:, + ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + end + + def deliver_error_webhook(moneyhash_error) + DeliverErrorWebhookService.call_async(payable, { + provider_customer_id: customer.moneyhash_customer.provider_customer_id, + provider_error: { + message: moneyhash_error.message, + error_code: moneyhash_error.error_code + } + }) + end + end + end +end diff --git a/app/services/payment_requests/payments/payment_providers/factory.rb b/app/services/payment_requests/payments/payment_providers/factory.rb index af20cad8fef..29f78d4de97 100644 --- a/app/services/payment_requests/payments/payment_providers/factory.rb +++ b/app/services/payment_requests/payments/payment_providers/factory.rb @@ -18,6 +18,8 @@ def self.service_class(payment_provider) PaymentRequests::Payments::CashfreeService when "gocardless" PaymentRequests::Payments::GocardlessService + when "moneyhash" + PaymentRequests::Payments::MoneyhashService else raise(NotImplementedError) end diff --git a/config/routes.rb b/config/routes.rb index 06ee08530a3..8a7f5d692f0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -95,6 +95,7 @@ post "cashfree/:organization_id", to: "webhooks#cashfree", on: :collection, as: :cashfree post "gocardless/:organization_id", to: "webhooks#gocardless", on: :collection, as: :gocardless post "adyen/:organization_id", to: "webhooks#adyen", on: :collection, as: :adyen + post "moneyhash/:organization_id", to: "webhooks#moneyhash", on: :collection, as: :moneyhash end namespace :admin do diff --git a/schema.graphql b/schema.graphql index 3b496afd32c..5497287e0a6 100644 --- a/schema.graphql +++ b/schema.graphql @@ -64,6 +64,22 @@ input AddGocardlessPaymentProviderInput { successRedirectUrl: String } +""" +Moneyhash input arguments +""" +input AddMoneyhashPaymentProviderInput { + apiKey: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + flowId: String! + name: String! + successRedirectUrl: String +} + type AddOn { amountCents: BigInt! amountCurrency: CurrencyEnum! @@ -4868,6 +4884,15 @@ type Metadata { totalPages: Int! } +type MoneyhashProvider { + apiKey: String + code: String! + flowId: String + id: ID! + name: String! + successRedirectUrl: String +} + type Mrr { amountCents: BigInt currency: CurrencyEnum @@ -4930,6 +4955,16 @@ type Mutation { input: AddGocardlessPaymentProviderInput! ): GocardlessProvider + """ + Add Moneyhash payment provider + """ + addMoneyhashPaymentProvider( + """ + Parameters for AddMoneyhashPaymentProvider + """ + input: AddMoneyhashPaymentProviderInput! + ): MoneyhashProvider + """ Add Stripe API keys to the organization """ @@ -5978,6 +6013,16 @@ type Mutation { input: UpdateMembershipInput! ): Membership + """ + Update Moneyhash payment provider + """ + updateMoneyhashPaymentProvider( + """ + Parameters for UpdateMoneyhashPaymentProvider + """ + input: UpdateMoneyhashPaymentProviderInput! + ): MoneyhashProvider + """ Update Netsuite integration """ @@ -6220,7 +6265,7 @@ type OverdueBalanceCollection { metadata: CollectionMetadata! } -union PaymentProvider = AdyenProvider | CashfreeProvider | GocardlessProvider | StripeProvider +union PaymentProvider = AdyenProvider | CashfreeProvider | GocardlessProvider | MoneyhashProvider | StripeProvider """ PaymentProviderCollection type @@ -6507,6 +6552,7 @@ enum ProviderTypeEnum { adyen cashfree gocardless + moneyhash stripe } @@ -8322,6 +8368,7 @@ input UpdateAdyenPaymentProviderInput { """ clientMutationId: String code: String + flowId: String id: ID! name: String successRedirectUrl: String @@ -8387,6 +8434,7 @@ input UpdateCashfreePaymentProviderInput { """ clientMutationId: String code: String + flowId: String id: ID! name: String successRedirectUrl: String @@ -8559,6 +8607,7 @@ input UpdateGocardlessPaymentProviderInput { """ clientMutationId: String code: String + flowId: String id: ID! name: String successRedirectUrl: String @@ -8671,6 +8720,21 @@ input UpdateMembershipInput { role: MembershipRole! } +""" +Update input arguments +""" +input UpdateMoneyhashPaymentProviderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + flowId: String + id: ID! + name: String + successRedirectUrl: String +} + """ Autogenerated input type of UpdateNetsuiteIntegration """ @@ -8806,6 +8870,7 @@ input UpdateStripePaymentProviderInput { """ clientMutationId: String code: String + flowId: String id: ID! name: String successRedirectUrl: String diff --git a/schema.json b/schema.json index 31eea4d6d4b..f69a9cf9a4b 100644 --- a/schema.json +++ b/schema.json @@ -381,6 +381,105 @@ ], "enumValues": null }, + { + "kind": "INPUT_OBJECT", + "name": "AddMoneyhashPaymentProviderInput", + "description": "Moneyhash input arguments", + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": [ + { + "name": "apiKey", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "enumValues": null + }, { "kind": "OBJECT", "name": "AddOn", @@ -23732,6 +23831,101 @@ "inputFields": null, "enumValues": null }, + { + "kind": "OBJECT", + "name": "MoneyhashProvider", + "description": null, + "interfaces": [], + "possibleTypes": null, + "fields": [ + { + "name": "apiKey", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [] + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [] + }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [] + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [] + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [] + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [] + } + ], + "inputFields": null, + "enumValues": null + }, { "kind": "OBJECT", "name": "Mrr", @@ -23957,6 +24151,35 @@ } ] }, + { + "name": "addMoneyhashPaymentProvider", + "description": "Add Moneyhash payment provider", + "type": { + "kind": "OBJECT", + "name": "MoneyhashProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + { + "name": "input", + "description": "Parameters for AddMoneyhashPaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AddMoneyhashPaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "name": "addStripePaymentProvider", "description": "Add Stripe API keys to the organization", @@ -27039,6 +27262,35 @@ } ] }, + { + "name": "updateMoneyhashPaymentProvider", + "description": "Update Moneyhash payment provider", + "type": { + "kind": "OBJECT", + "name": "MoneyhashProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null, + "args": [ + { + "name": "input", + "description": "Parameters for UpdateMoneyhashPaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateMoneyhashPaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, { "name": "updateNetsuiteIntegration", "description": "Update Netsuite integration", @@ -28345,6 +28597,11 @@ "name": "GocardlessProvider", "ofType": null }, + { + "kind": "OBJECT", + "name": "MoneyhashProvider", + "ofType": null + }, { "kind": "OBJECT", "name": "StripeProvider", @@ -31292,6 +31549,12 @@ "description": null, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "moneyhash", + "description": null, + "isDeprecated": false, + "deprecationReason": null } ] }, @@ -38988,6 +39251,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "id", "description": null, @@ -39392,6 +39667,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "id", "description": null, @@ -40732,6 +41019,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "id", "description": null, @@ -41432,6 +41731,93 @@ ], "enumValues": null }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateMoneyhashPaymentProviderInput", + "description": "Update input arguments", + "interfaces": null, + "possibleTypes": null, + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "enumValues": null + }, { "kind": "INPUT_OBJECT", "name": "UpdateNetsuiteIntegrationInput", @@ -42470,6 +42856,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "id", "description": null,