diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 8a666a4dd..f91fc92ec 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -1,6 +1,6 @@ -class PaymentsController < ApplicationController - before_action :authenticate_user!, only: %i[index create add] - after_action :verify_authorized, only: %i[index create add] +class PaymentsController < ApplicationController # rubocop:disable Metrics/ClassLength + before_action :authenticate_user!, only: %i[index create add setup_mandate mandate_callback toggle_auto_charge] + after_action :verify_authorized, only: %i[index create add setup_mandate toggle_auto_charge] def index @payments = Payment.order(created_at: :desc) @@ -37,6 +37,90 @@ def add # rubocop:disable Metrics/AbcSize @payment.amount = params[:resulting_credit].to_f - @user.credit if params[:resulting_credit] end + def setup_mandate # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + authorize :payment + + if Rails.application.config.x.mollie_api_key.blank? + flash[:error] = 'iDEAL is niet beschikbaar' + redirect_to user_path(current_user) + return + end + + begin + # Create or retrieve Mollie customer + mollie_customer = if current_user.mollie_customer_id.present? + Mollie::Customer.get(current_user.mollie_customer_id) + else + Mollie::Customer.create( + name: current_user.name, + email: current_user.email + ) + end + + current_user.update(mollie_customer_id: mollie_customer.id) + + # Create payment for 1 cent to set up mandate + payment = Payment.create_with_mollie( + 'Automatische opwaardering setup (1 cent)', + user: current_user, + amount: 0.01, + first_payment: true + ) + + if payment.valid? + checkout_url = payment.mollie_payment.checkout_url + redirect_to URI.parse(checkout_url).to_s, allow_other_host: true + else + flash[:error] = "Kon betaling niet aanmaken: #{payment.errors.full_messages.join(', ')}" + redirect_to user_path(current_user) + end + rescue Mollie::ResponseError => e + flash[:error] = "Mollie fout: #{e.message}" + redirect_to user_path(current_user) + end + end + + def mandate_callback # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity + payment = Payment.find(params[:id]) + unless payment + redirect_to users_path + return + end + + if payment.completed? + flash[:error] = 'Deze betaling is al verwerkt' + else + tries = 3 + begin + payment.update(status: payment.mollie_payment.status) + + if payment.mollie_payment.paid? + # Extract mandate from payment + if payment.mollie_payment.mandate_id + current_user.update(mollie_mandate_id: payment.mollie_payment.mandate_id) + flash[:success] = 'Automatische opwaardering ingesteld! Je kan nu automatische opwaardering inschakelen.' + else + flash[:success] = 'Betaling gelukt, maar mandate kon niet worden opgeslagen.' + end + else + flash[:error] = 'iDEAL betaling is mislukt. Mandate kon niet worden ingesteld.' + end + rescue ActiveRecord::StaleObjectError => e + raise e unless (tries -= 1).positive? + + payment = Payment.find(params[:id]) + retry + end + end + + redirect_to user_path(current_user) + end + + def toggle_auto_charge + authorize :payment + toggle_auto_charge_handler + end + def callback # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/PerceivedComplexity payment = Payment.find(params[:id]) unless payment @@ -73,4 +157,35 @@ def callback # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/Pe redirect_to invoice_path(payment.invoice.token) end end + + private + + def toggle_auto_charge_handler + return handle_invalid_mandate unless valid_mandate? + + toggle_user_auto_charge + redirect_to user_path(current_user) + end + + def valid_mandate? + current_user.mollie_mandate_id.present? && current_user.mandate_valid? + end + + def handle_invalid_mandate + flash[:error] = 'Je hebt geen geldige mandate ingesteld' + redirect_to user_path(current_user) + end + + def toggle_user_auto_charge + current_user.update(auto_charge_enabled: !current_user.auto_charge_enabled) + set_auto_charge_flash_message + end + + def set_auto_charge_flash_message + if current_user.auto_charge_enabled + flash[:success] = 'Automatische opwaardering ingeschakeld' + else + flash[:warning] = 'Automatische opwaardering uitgeschakeld' + end + end end diff --git a/app/jobs/auto_charge_job.rb b/app/jobs/auto_charge_job.rb new file mode 100644 index 000000000..32bf02a79 --- /dev/null +++ b/app/jobs/auto_charge_job.rb @@ -0,0 +1,72 @@ +class AutoChargeJob < ApplicationJob + queue_as :default + + def perform + # Find all users with auto_charge_enabled and valid mandates with negative credit + User.where(auto_charge_enabled: true).find_each do |user| + process_user_charge(user) + end + + perform_health_check + end + + private + + def process_user_charge(user) + return unless user.mandate_valid? + return unless user.credit.negative? + + charge_user(user) + rescue StandardError => e + Rails.logger.error("AutoChargeJob failed for user #{user.id}: #{e.message}") + end + + def perform_health_check + HealthCheckJob.perform_later('auto_charge') if production_or_staging? + end + + def production_or_staging? + Rails.env.production? || Rails.env.staging? || Rails.env.luxproduction? || Rails.env.euros? + end + + def charge_user(user) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + # Calculate amount needed to bring balance back to 0 or add buffer + amount_needed = (user.credit.abs + 1.00).round(2) # Add 1 euro buffer + + # Cap at reasonable amount to prevent accidental large charges + amount_needed = 100.00 if amount_needed > 100.00 + + return if amount_needed < 0.01 + + # Create payment with mandate using Mollie API + mollie_customer = Mollie::Customer.get(user.mollie_customer_id) + mollie_mandate = mollie_customer.mandates.get(user.mollie_mandate_id) + + return unless mollie_mandate.status == 'valid' + + # Create a recurring payment (charge) + mollie_payment = mollie_customer.payments.create( + amount: { value: format('%.2f', amount_needed), currency: 'EUR' }, + description: 'Automatische opwaardering (onderschrijving)', + mandateId: user.mollie_mandate_id, + sequenceType: 'recurring', + redirectUrl: "http://#{Rails.application.config.x.sofia_host}/users/#{user.id}" + ) + + # Create payment record in database + payment = Payment.create( + user:, + amount: amount_needed, + mollie_id: mollie_payment.id, + status: mollie_payment.status + ) + + Rails.logger.info("AutoChargeJob created payment #{payment.id} for user #{user.id} with amount #{amount_needed}") + + # If payment is already paid (for recurring), process it immediately + payment.update(status: 'paid') if mollie_payment.paid? + rescue Mollie::ResponseError => e + Rails.logger.error("Mollie API error in AutoChargeJob for user #{user.id}: #{e.message}") + raise + end +end diff --git a/app/models/activity.rb b/app/models/activity.rb index de04f2785..1bcd6aeb9 100644 --- a/app/models/activity.rb +++ b/app/models/activity.rb @@ -62,9 +62,9 @@ def revenue_total def count_per_product(**args) records = orders.where(args) - @count_per_product = OrderRow.where(order: records).group(:product_id, :name).joins(:product) - .pluck(:name, Arel.sql('SUM(product_count)'), Arel.sql('SUM(product_count * price_per_product)')) - @count_per_product.map { |name, amount, price| { name:, amount: amount.to_i, price: price.to_f } } + rows = OrderRow.where(order: records).group(:product_id, :name).joins(:product) + .pluck(:name, Arel.sql('SUM(product_count)'), Arel.sql('SUM(product_count * price_per_product)')) + rows.map { |name, amount, price| { name:, amount: amount.to_i, price: price.to_f } } end def revenue_by_category diff --git a/app/models/payment.rb b/app/models/payment.rb index 76d897d9b..404d30c42 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -27,19 +27,44 @@ def completed? end def self.create_with_mollie(description, attributes = nil) + is_mandate_setup = attributes&.delete(:first_payment) obj = create(attributes) return obj unless obj.valid? - mollie_payment = Mollie::Payment.create( - amount: { value: format('%.2f', amount: attributes[:amount]), currency: 'EUR' }, - description:, - redirect_url: "http://#{Rails.application.config.x.sofia_host}/payments/#{obj.id}/callback" - ) - + mollie_payment = create_mollie_payment(description, attributes, obj, is_mandate_setup) obj.update(mollie_id: mollie_payment.id) obj end + def self.create_mollie_payment(description, attributes, obj, is_mandate_setup) + mollie_payment_attrs = build_mollie_attrs(description, attributes, obj, is_mandate_setup) + Mollie::Payment.create(mollie_payment_attrs) + end + + def self.build_mollie_attrs(description, attributes, obj, is_mandate_setup) + attrs = build_base_mollie_attrs(description, attributes) + add_redirect_url(attrs, obj, is_mandate_setup) + attrs + end + + def self.build_base_mollie_attrs(description, attributes) + { + amount: { value: format('%.2f', attributes[:amount]), currency: 'EUR' }, + description: + } + end + + def self.add_redirect_url(attrs, obj, is_mandate_setup) + if is_mandate_setup + attrs[:sequenceType] = 'first' + attrs[:redirectUrl] = "http://#{Rails.application.config.x.sofia_host}/payments/#{obj.id}/mandate_callback" + else + attrs[:redirectUrl] = "http://#{Rails.application.config.x.sofia_host}/payments/#{obj.id}/callback" + end + end + + private_class_method :create_mollie_payment, :build_mollie_attrs, :build_base_mollie_attrs, :add_redirect_url + def mollie_payment Mollie::Payment.get(mollie_id) end @@ -47,10 +72,17 @@ def mollie_payment def process_complete_payment! return unless status_previously_was != 'paid' && status == 'paid' + # Skip credit mutation for mandate setup payments (1 cent) + return if setup_payment? + process_user! if user process_invoice! if invoice end + def setup_payment? + amount.to_d == 0.01.to_d + end + def process_user! mutation = CreditMutation.create(user:, amount:, @@ -76,6 +108,8 @@ def user_xor_invoice def user_amount return unless user + # Allow 1 cent payments for mandate setup + return if setup_payment? min_amount = Rails.application.config.x.min_payment_amount errors.add(:amount, "must be bigger than or equal to €#{format('%.2f', min_amount)}") unless amount && (amount >= min_amount) diff --git a/app/models/user.rb b/app/models/user.rb index de744e3ea..32a3a3276 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -71,11 +71,17 @@ def minor end def insufficient_credit + # Users with auto-charge enabled are always allowed to order + return false if auto_charge_available? + provider.in?(%w[amber_oauth2 sofia_account]) && credit.negative? end def can_order(activity = nil) activity ||= current_activity + # Users with auto-charge enabled can always order + return true if auto_charge_available? + if activity.nil? !insufficient_credit else @@ -105,9 +111,33 @@ def update_role(groups) roles_users_not_to_have.map(&:destroy) end + def mollie_customer + return if mollie_customer_id.blank? + + @mollie_customer ||= Mollie::Customer.get(mollie_customer_id) + rescue Mollie::ResponseError + nil + end + + def mollie_mandate + return nil unless mollie_mandate_id.present? && mollie_customer.present? + + mollie_customer.mandates.get(mollie_mandate_id) + rescue Mollie::ResponseError + nil + end + + def mandate_valid? + mollie_mandate&.status == 'valid' + end + + def auto_charge_available? + auto_charge_enabled && mandate_valid? + end + def archive! attributes.each_key do |attribute| - self[attribute] = nil unless %w[deleted_at updated_at created_at provider id uid].include? attribute + self[attribute] = nil unless %w[deleted_at updated_at created_at provider id uid auto_charge_enabled].include? attribute end self.name = "Gearchiveerde gebruiker #{id}" self.deactivated = true diff --git a/app/policies/payment_policy.rb b/app/policies/payment_policy.rb index 203252c51..25d3e281a 100644 --- a/app/policies/payment_policy.rb +++ b/app/policies/payment_policy.rb @@ -11,6 +11,18 @@ def add? mollie_enabled? && user end + def setup_mandate? + mollie_enabled? && user + end + + def mandate_callback? + mollie_enabled? && user + end + + def toggle_auto_charge? + mollie_enabled? && user + end + def invoice_callback? mollie_enabled? && record && !record.completed? end diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 6bf5b2e74..495fb8946 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -102,6 +102,11 @@ Inleggen +