├── .rspec ├── app ├── views │ └── spree │ │ └── abandoned_cart_mailer │ │ └── abandoned_cart_email.html.erb ├── jobs │ └── spree │ │ ├── notify_abandoned_cart_job.rb │ │ └── schedule_abandoned_carts_job.rb ├── mailers │ └── spree │ │ └── abandoned_cart_mailer.rb ├── services │ └── spree │ │ └── abandoned_cart_notifier.rb └── decorators │ └── models │ └── solidus_abandoned_carts │ └── spree │ └── order_decorator.rb ├── config ├── locales │ ├── it.yml │ ├── en.yml │ ├── es.yml │ └── es-MX.yml └── routes.rb ├── lib ├── solidus_abandoned_carts │ ├── factories.rb │ ├── version.rb │ ├── engine.rb │ └── configuration.rb ├── solidus_abandoned_carts.rb ├── tasks │ └── solidus_abandoned_carts │ │ └── send_notification.rake └── generators │ └── solidus_abandoned_carts │ └── install │ ├── templates │ └── initializer.rb │ └── install_generator.rb ├── .gem_release.yml ├── bin ├── setup ├── rails └── console ├── Rakefile ├── .gitignore ├── .rubocop.yml ├── db └── migrate │ └── 20180712163605_add_abandoned_cart_email_sent_at_to_spree_orders.rb ├── spec ├── mailers │ └── spree │ │ └── abandoned_cart_mailer_spec.rb ├── spec_helper.rb ├── support │ └── rake.rb ├── jobs │ └── spree │ │ ├── notify_abandoned_cart_job_spec.rb │ │ └── schedule_abandoned_carts_job_spec.rb ├── lib │ └── tasks │ │ └── solidus_abandoned_carts │ │ └── send_notification_rake_spec.rb ├── services │ └── spree │ │ └── abandoned_cart_notifier_spec.rb └── models │ └── spree │ └── order_spec.rb ├── .github └── stale.yml ├── .circleci └── config.yml ├── Gemfile ├── solidus_abandoned_carts.gemspec ├── LICENSE └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /app/views/spree/abandoned_cart_mailer/abandoned_cart_email.html.erb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/locales/it.yml: -------------------------------------------------------------------------------- 1 | it: 2 | abandoned_cart_subject: Abandoned Cart -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | abandoned_cart_subject: Abandoned Cart 3 | -------------------------------------------------------------------------------- /config/locales/es.yml: -------------------------------------------------------------------------------- 1 | es: 2 | spree: 3 | abandoned_cart_subject: Orden Abandonada 4 | -------------------------------------------------------------------------------- /config/locales/es-MX.yml: -------------------------------------------------------------------------------- 1 | es-MX: 2 | spree: 3 | abandoned_cart_subject: Orden Abandonada 4 | -------------------------------------------------------------------------------- /lib/solidus_abandoned_carts/factories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | end 5 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Spree::Core::Engine.routes.draw do 4 | # Add your extension routes here 5 | end 6 | -------------------------------------------------------------------------------- /lib/solidus_abandoned_carts/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusAbandonedCarts 4 | VERSION = '2.0.0' 5 | end 6 | -------------------------------------------------------------------------------- /.gem_release.yml: -------------------------------------------------------------------------------- 1 | bump: 2 | recurse: false 3 | file: 'lib/solidus_abandoned_carts/version.rb' 4 | message: Bump SolidusAbandonedCarts to %{version} 5 | branch: true 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | gem install bundler --conservative 7 | bundle update 8 | bundle exec rake extension:test_app 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'solidus_dev_support/rake_tasks' 4 | SolidusDevSupport::RakeTasks.install 5 | 6 | task default: %w[extension:test_app extension:specs] 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | \#* 3 | *~ 4 | .#* 5 | .DS_Store 6 | .idea 7 | .project 8 | .sass-cache 9 | coverage 10 | Gemfile.lock 11 | tmp 12 | nbproject 13 | pkg 14 | *.swp 15 | spec/dummy 16 | spec/examples.txt 17 | -------------------------------------------------------------------------------- /lib/solidus_abandoned_carts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'solidus_core' 4 | require 'solidus_support' 5 | 6 | require 'solidus_abandoned_carts/engine' 7 | require 'solidus_abandoned_carts/configuration' 8 | require 'solidus_abandoned_carts/version' 9 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - solidus_dev_support/rubocop 3 | 4 | inherit_gem: 5 | solidus_dev_support: .rubocop.yml 6 | 7 | AllCops: 8 | Exclude: 9 | - spec/dummy/**/* 10 | - vendor/**/* 11 | 12 | Rails/SkipsModelValidations: 13 | Exclude: 14 | - db/migrate/**/* 15 | -------------------------------------------------------------------------------- /db/migrate/20180712163605_add_abandoned_cart_email_sent_at_to_spree_orders.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class AddAbandonedCartEmailSentAtToSpreeOrders < SolidusSupport::Migration[4.2] 4 | def change 5 | add_column :spree_orders, :abandoned_cart_email_sent_at, :datetime 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/jobs/spree/notify_abandoned_cart_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Spree 4 | class NotifyAbandonedCartJob < ActiveJob::Base 5 | queue_as :default 6 | 7 | def perform(order) 8 | return unless order.last_for_user? 9 | 10 | SolidusAbandonedCarts::Config.notifier_class.new(order).call 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # frozen_string_literal: true 4 | 5 | app_root = 'spec/dummy' 6 | 7 | unless File.exist? "#{app_root}/bin/rails" 8 | system "bin/rake", app_root or begin # rubocop:disable Style/AndOr 9 | warn "Automatic creation of the dummy app failed" 10 | exit 1 11 | end 12 | end 13 | 14 | Dir.chdir app_root 15 | exec 'bin/rails', *ARGV 16 | -------------------------------------------------------------------------------- /spec/mailers/spree/abandoned_cart_mailer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Spree::AbandonedCartMailer do 4 | describe '.abandoned_cart_email' do 5 | subject(:email) { described_class.abandoned_cart_email(order) } 6 | 7 | let(:order) { build_stubbed(:order) } 8 | 9 | it "is sent to the order's email" do 10 | expect(email.to).to eq([order.email]) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/jobs/spree/schedule_abandoned_carts_job.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Spree 4 | class ScheduleAbandonedCartsJob < ActiveJob::Base 5 | queue_as :default 6 | 7 | def perform 8 | Spree::Order.abandon_not_notified.find_each do |order| 9 | next unless order.last_for_user? 10 | 11 | SolidusAbandonedCarts::Config.notifier_job_class.perform_later(order) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /app/mailers/spree/abandoned_cart_mailer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Spree 4 | class AbandonedCartMailer < BaseMailer 5 | def abandoned_cart_email(order) 6 | @order = order 7 | @store = @order.store 8 | subject = "#{@store.name} - #{I18n.t('spree.abandoned_cart_subject')}" 9 | 10 | mail(to: order.email, from: from_address(@store), subject: subject) if @order.email.present? 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # frozen_string_literal: true 4 | 5 | require "bundler/setup" 6 | require "solidus_abandoned_carts" 7 | 8 | # You can add fixtures and/or initialization code here to make experimenting 9 | # with your gem easier. You can also use a different console, if you like. 10 | $LOAD_PATH.unshift(*Dir["#{__dir__}/../app/*"]) 11 | 12 | # (If you use this, don't forget to add pry to your Gemfile!) 13 | # require "pry" 14 | # Pry.start 15 | 16 | require "irb" 17 | IRB.start(__FILE__) 18 | -------------------------------------------------------------------------------- /app/services/spree/abandoned_cart_notifier.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Spree 4 | class AbandonedCartNotifier 5 | def initialize(order) 6 | @order = order 7 | end 8 | 9 | def call 10 | return if order.abandoned_cart_email_sent_at 11 | 12 | SolidusAbandonedCarts::Config.mailer_class.abandoned_cart_email(order).deliver_now 13 | 14 | order.touch(:abandoned_cart_email_sent_at) 15 | end 16 | 17 | private 18 | 19 | attr_reader :order 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/solidus_abandoned_carts/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spree/core' 4 | 5 | module SolidusAbandonedCarts 6 | class Engine < Rails::Engine 7 | include SolidusSupport::EngineExtensions 8 | 9 | isolate_namespace Spree 10 | 11 | engine_name 'solidus_abandoned_carts' 12 | 13 | # use rspec for tests 14 | config.generators do |g| 15 | g.test_framework :rspec 16 | end 17 | 18 | initializer 'solidus_abandoned_carts.environment', before: :load_config_initializers do 19 | SolidusAbandonedCarts::Config = SolidusAbandonedCarts::Configuration.new 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/tasks/solidus_abandoned_carts/send_notification.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :solidus_abandoned_carts do 4 | task send_notification: :environment do 5 | puts "Sending abandoned carts notifications..." 6 | 7 | abandonded_carts = Spree::Order.abandon_not_notified 8 | if abandonded_carts 9 | abandonded_carts.find_each do |order| 10 | next unless order.last_for_user? 11 | 12 | Spree::NotifyAbandonedCartJob.perform_now(order) 13 | end if abandonded_carts 14 | 15 | puts "notifications sent: #{abandonded_carts.count}" 16 | end 17 | 18 | puts "Done!" 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Configure Rails Environment 4 | ENV['RAILS_ENV'] = 'test' 5 | 6 | # Run Coverage report 7 | require 'solidus_dev_support/rspec/coverage' 8 | 9 | require File.expand_path('dummy/config/environment.rb', __dir__) 10 | 11 | # Requires factories and other useful helpers defined in spree_core. 12 | require 'solidus_dev_support/rspec/feature_helper' 13 | 14 | require 'rspec/rails' 15 | 16 | Dir[File.join(File.dirname(__FILE__), '/support/**/*.rb')].each { |file| require file } 17 | 18 | RSpec.configure do |config| 19 | config.infer_spec_type_from_file_location! 20 | config.use_transactional_fixtures = false 21 | end 22 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false -------------------------------------------------------------------------------- /lib/solidus_abandoned_carts/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusAbandonedCarts 4 | class Configuration < Spree::Preferences::Configuration 5 | preference :abandoned_states, :array, default: %i[cart address delivery payment confirm] 6 | preference :abandoned_timeout, :time, default: 24.hours 7 | preference :abandoned_retroactivity, :time, default: nil 8 | 9 | class_name_attribute :mailer_class, default: 'Spree::AbandonedCartMailer' 10 | class_name_attribute :notifier_class, default: 'Spree::AbandonedCartNotifier' 11 | class_name_attribute :notifier_job_class, default: 'Spree::NotifyAbandonedCartJob' 12 | class_name_attribute :schedule_job_class, default: 'Spree::ScheduleAbandonedCartsJob' 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/generators/solidus_abandoned_carts/install/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | SolidusAbandonedCarts::Config.tap do |config| 4 | # Override timeout which make an order abandoned 5 | # config.abandoned_timeout = 24.hours 6 | 7 | # Override the abandoned states 8 | # config.abandoned_states = %i[cart address delivery payment confirm] 9 | 10 | # Override the time which restricts the abandoned orders 11 | # avoiding that the older ones aren't considered abandoned. 12 | # Can be set to nil to remove this restriction 13 | config.abandoned_retroactivity = 1.month 14 | 15 | # Override mailer classes 16 | # config.mailer_class = 'Spree::AbandonedCartMailer' 17 | # config.notifier_class = 'Spree::AbandonedCartNotifier' 18 | 19 | # Override job classes 20 | # config.notifier_job_class = 'Spree::NotifyAbandonedCartJob' 21 | # config.schedule_job_class = 'Spree::ScheduleAbandonedCartsJob' 22 | end 23 | -------------------------------------------------------------------------------- /spec/support/rake.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # 4 | # Rake task spec setup. 5 | # 6 | RSpec.shared_context "rake" do |task_path:, task_name:| 7 | require 'rake' 8 | 9 | let(:task) do 10 | Rake::Task[task_name] 11 | end 12 | 13 | before(:each) do 14 | # we need to reenable the task or else `task.invoke` will only run the task 15 | # for the first example that runs. 16 | task.reenable 17 | end 18 | 19 | before(:all) do 20 | Rake::Task.clear 21 | # Note: Using `Rails.application.load_tasks` doesn't seem to work correctly 22 | # in the specs. The tasks each run twice when invoked instead of once. 23 | load task_path 24 | # Many tasks require the 'environment' task, which isn't needed in specs 25 | # since the environment is already loaded. So generate a fake one. 26 | Rake::Task.define_task(:environment) 27 | end 28 | 29 | after(:all) do 30 | Rake::Task.clear 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/jobs/spree/notify_abandoned_cart_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Spree::NotifyAbandonedCartJob, type: :job do 4 | subject { -> { described_class.perform_now(order) } } 5 | 6 | let(:order) { instance_spy('Spree::Order', last_for_user?: last_for_user) } 7 | 8 | let(:notifier) { instance_spy('Spree::AbandonedCartNotifier') } 9 | 10 | before do 11 | allow(Spree::AbandonedCartNotifier).to receive(:new) 12 | .with(order) 13 | .and_return(notifier) 14 | end 15 | 16 | context "when the order is the user's last" do 17 | let(:last_for_user) { true } 18 | 19 | it 'runs the notifier class' do 20 | subject.call 21 | 22 | expect(notifier).to have_received(:call) 23 | end 24 | end 25 | 26 | context "when the order is not the user's last anymore" do 27 | let(:last_for_user) { false } 28 | 29 | it 'does not run the notifier class' do 30 | subject.call 31 | 32 | expect(notifier).not_to have_received(:call) 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/generators/solidus_abandoned_carts/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusAbandonedCarts 4 | module Generators 5 | class InstallGenerator < Rails::Generators::Base 6 | source_root File.expand_path('templates', __dir__) 7 | 8 | class_option :auto_run_migrations, type: :boolean, default: false 9 | 10 | def add_migrations 11 | run 'bundle exec rake railties:install:migrations FROM=solidus_abandoned_carts' 12 | end 13 | 14 | def run_migrations 15 | run_migrations = options[:auto_run_migrations] || ['', 'y', 'Y'].include?(ask('Would you like to run the migrations now? [Y/n]')) 16 | if run_migrations 17 | run 'bundle exec rake db:migrate' 18 | else 19 | puts 'Skipping rake db:migrate, don\'t forget to run it!' # rubocop:disable Rails/Output 20 | end 21 | end 22 | 23 | def copy_initializer 24 | copy_file 'initializer.rb', 'config/initializers/solidus_abandoned_carts.rb' 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | # Always take the latest version of the orb, this allows us to 5 | # run specs against Solidus supported versions only without the need 6 | # to change this configuration every time a Solidus version is released 7 | # or goes EOL. 8 | solidusio_extensions: solidusio/extensions@volatile 9 | 10 | jobs: 11 | run-specs-with-postgres: 12 | executor: solidusio_extensions/postgres 13 | steps: 14 | - solidusio_extensions/run-tests 15 | run-specs-with-mysql: 16 | executor: solidusio_extensions/mysql 17 | steps: 18 | - solidusio_extensions/run-tests 19 | 20 | workflows: 21 | "Run specs on supported Solidus versions": 22 | jobs: 23 | - run-specs-with-postgres 24 | - run-specs-with-mysql 25 | "Weekly run specs against master": 26 | triggers: 27 | - schedule: 28 | cron: "0 0 * * 4" # every Thursday 29 | filters: 30 | branches: 31 | only: 32 | - master 33 | jobs: 34 | - run-specs-with-postgres 35 | - run-specs-with-mysql 36 | -------------------------------------------------------------------------------- /spec/lib/tasks/solidus_abandoned_carts/send_notification_rake_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe 'solidus_abandoned_carts:send_notification' do 4 | let(:user) { create(:user) } 5 | let!(:order1) do 6 | order = create(:order_with_line_items, user: user) 7 | order.update(updated_at: (Time.current - 48.hours), 8 | abandoned_cart_email_sent_at: nil) 9 | order 10 | end 11 | let!(:order2) do 12 | order = create(:order_with_line_items, user: user) 13 | order.update(updated_at: (Time.current - 48.hours), 14 | abandoned_cart_email_sent_at: nil) 15 | order 16 | end 17 | 18 | context 'perform' do 19 | include_context( 20 | 'rake', 21 | task_path: 'lib/tasks/solidus_abandoned_carts/send_notification.rake', 22 | task_name: 'solidus_abandoned_carts:send_notification', 23 | ) 24 | 25 | it 'runs' do 26 | expect { task.invoke }.to output( 27 | "Sending abandoned carts notifications...\nnotifications sent: 1\nDone!\n" 28 | ).to_stdout 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | branch = ENV.fetch('SOLIDUS_BRANCH', 'master') 7 | solidus_git, solidus_frontend_git = if (branch == 'master') || (branch >= 'v3.2') 8 | %w[solidusio/solidus solidusio/solidus_frontend] 9 | else 10 | %w[solidusio/solidus] * 2 11 | end 12 | gem 'solidus', github: solidus_git, branch: branch 13 | gem 'solidus_frontend', github: solidus_frontend_git, branch: branch 14 | 15 | # Needed to help Bundler figure out how to resolve dependencies, 16 | # otherwise it takes forever to resolve them. 17 | # See https://github.com/bundler/bundler/issues/6677 18 | gem 'rails', '>0.a' 19 | 20 | # Provides basic authentication functionality for testing parts of your engine 21 | gem 'solidus_auth_devise' 22 | 23 | case ENV['DB'] 24 | when 'mysql' 25 | gem 'mysql2' 26 | when 'postgresql' 27 | gem 'pg' 28 | else 29 | gem 'sqlite3' 30 | end 31 | 32 | gemspec 33 | -------------------------------------------------------------------------------- /app/decorators/models/solidus_abandoned_carts/spree/order_decorator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SolidusAbandonedCarts 4 | module Spree 5 | module OrderDecorator 6 | def self.prepended(base) 7 | base.scope :abandoned, ->(time = Time.current - SolidusAbandonedCarts::Config.abandoned_timeout) do 8 | incomplete. 9 | where('email IS NOT NULL'). 10 | where('item_count > 0'). 11 | where('updated_at < ?', time) 12 | end 13 | 14 | base.scope :abandon_not_notified, -> do 15 | relation = abandoned.where(abandoned_cart_email_sent_at: nil) 16 | 17 | if SolidusAbandonedCarts::Config.abandoned_retroactivity 18 | retroactivity = Time.current - SolidusAbandonedCarts::Config.abandoned_retroactivity 19 | relation = relation.where('updated_at > ?', retroactivity) 20 | end 21 | 22 | relation 23 | end 24 | end 25 | 26 | def last_for_user? 27 | ::Spree::Order.where(email: email).where('created_at > ?', created_at).none? 28 | end 29 | 30 | ::Spree::Order.prepend self 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/jobs/spree/schedule_abandoned_carts_job_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Spree::ScheduleAbandonedCartsJob, type: :job do 4 | subject { -> { described_class.perform_now } } 5 | 6 | let(:order) { instance_double('Spree::Order', last_for_user?: last_for_user) } 7 | 8 | before do 9 | relation = instance_double('Spree::Order::ActiveRecord_Relation') 10 | allow(Spree::Order).to receive(:abandon_not_notified).and_return(relation) 11 | allow(relation).to receive(:find_each).and_yield(order) 12 | stub_const('Spree::NotifyAbandonedCartJob', class_spy('Spree::NotifyAbandonedCartJob')) 13 | end 14 | 15 | context "when an order is the user's last" do 16 | let(:last_for_user) { true } 17 | 18 | it 'gets scheduled for notification' do 19 | subject.call 20 | 21 | expect(Spree::NotifyAbandonedCartJob).to have_received(:perform_later).with(order) 22 | end 23 | end 24 | 25 | context "when an order is not the user's last" do 26 | let(:last_for_user) { false } 27 | 28 | it 'gets scheduled for notification' do 29 | subject.call 30 | 31 | expect(Spree::NotifyAbandonedCartJob).not_to have_received(:perform_later) 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/services/spree/abandoned_cart_notifier_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Spree::AbandonedCartNotifier do 4 | subject(:notifier) { described_class.new(order) } 5 | 6 | let(:order) do 7 | instance_spy('Spree::Order', abandoned_cart_email_sent_at: abandoned_cart_email_sent_at) 8 | end 9 | 10 | let(:email) { instance_spy('ActionMailer::Delivery') } 11 | 12 | before do 13 | allow(Spree::AbandonedCartMailer).to receive(:abandoned_cart_email) 14 | .with(order) 15 | .and_return(email) 16 | end 17 | 18 | context 'when the order has not been notified yet' do 19 | let(:abandoned_cart_email_sent_at) { nil } 20 | 21 | it 'sends send the abandoned cart email' do 22 | notifier.call 23 | 24 | expect(email).to have_received(:deliver_now) 25 | end 26 | 27 | it 'touches abandoned_cart_email_sent_at' do 28 | notifier.call 29 | 30 | expect(order).to have_received(:touch).with(:abandoned_cart_email_sent_at) 31 | end 32 | end 33 | 34 | context 'when the order has already been notified' do 35 | let(:abandoned_cart_email_sent_at) { Time.zone.now } 36 | 37 | it 'does not send the abandoned cart email' do 38 | notifier.call 39 | 40 | expect(email).not_to have_received(:deliver_now) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /solidus_abandoned_carts.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $:.push File.expand_path('lib', __dir__) 4 | require 'solidus_abandoned_carts/version' 5 | 6 | Gem::Specification.new do |s| 7 | s.platform = Gem::Platform::RUBY 8 | s.name = 'solidus_abandoned_carts' 9 | s.version = SolidusAbandonedCarts::VERSION 10 | s.summary = 'Take some action for abandoned carts' 11 | s.description = s.summary 12 | s.license = 'BSD-3-Clause' 13 | s.author = 'Jonathan Tapia' 14 | s.email = 'jonathan.tapia@magmalabs.io' 15 | s.homepage = 'https://github.com/solidusio-contrib/solidus_abandoned_carts' 16 | 17 | if s.respond_to?(:metadata) 18 | s.metadata["homepage_uri"] = s.homepage if s.homepage 19 | s.metadata["source_code_uri"] = s.homepage if s.homepage 20 | end 21 | 22 | s.required_ruby_version = ['>= 2.4', '< 4.0'] 23 | 24 | s.files = Dir.chdir(File.expand_path(__dir__)) do 25 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 26 | end 27 | s.test_files = Dir['spec/**/*'] 28 | s.bindir = "exe" 29 | s.executables = s.files.grep(%r{^exe/}) { |f| File.basename(f) } 30 | s.require_paths = ["lib"] 31 | 32 | s.add_dependency 'solidus_core', ['>= 2.0.0', '< 4'] 33 | s.add_dependency 'solidus_support', '~> 0.5' 34 | 35 | s.add_development_dependency 'solidus_dev_support' 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Alessandro Lepore 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | * Neither the name Spree nor the names of its contributors may be used to 13 | endorse or promote products derived from this software without specific 14 | prior written permission. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 20 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 21 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 22 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 23 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 24 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 25 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SolidusAbandonedCarts 2 | 3 | [![CircleCI](https://circleci.com/gh/solidusio-contrib/solidus_abandoned_carts.svg?style=svg)](https://circleci.com/gh/solidusio-contrib/solidus_abandoned_carts) 4 | 5 | Take action on your abandoned carts! 6 | 7 | ## Installation 8 | 9 | Add this line to your application's Gemfile: 10 | 11 | ```ruby 12 | gem 'solidus_abandoned_carts', github: 'solidusio-contrib/solidus_abandoned_carts' 13 | ``` 14 | 15 | Then run the following: 16 | 17 | ```console 18 | $ bundle install 19 | $ bundle exec rails g solidus_abandoned_carts:install 20 | ``` 21 | 22 | If you want to change the configuration, you can add the following to an initializer: 23 | 24 | ```ruby 25 | SolidusAbandonedCarts::Config.tap do |config| 26 | # Amount of time after which a cart is considered abandoned. 27 | config.abandoned_timeout = 24.hours 28 | 29 | # The states in which a cart is considered to be abandoned. 30 | config.abandoned_states = [:cart, :address, :delivery, :payment, :confirm] 31 | 32 | # Set your own notifier class 33 | config.notifier_class = 'Spree::AbandonedCartNotifier' 34 | 35 | # Set your own mailer class 36 | config.mailer_class = 'Spree::AbandonedCartMailer' 37 | 38 | # Set your own notifier job class 39 | config.notifier_job_class = 'Spree::NotifyAbandonedCartJob' 40 | 41 | # Set your own schedule job class 42 | config.schedule_job_class = 'Spree::ScheduleAbandonedCartsJob' 43 | end 44 | ``` 45 | 46 | The last step in the installation process is to configure the `Spree::ScheduleAbandonedCartsJob` 47 | background job to run regularly. There are different ways to do this depending on the environment 48 | your application is running in: Heroku Scheduler, cron etc. 49 | 50 | ## Usage 51 | 52 | If you're okay with the default behavior of sending an abandoned cart email, you can simply override 53 | the `spree.abandoned_cart_subject` translation key and the `spree/abandoned_cart_mailer/abandoned_cart_email.html.erb` 54 | view. The default notifier will take care of sending the email for you. 55 | 56 | If, on the other hand, you want to use custom logic, keep reading! 57 | 58 | ### Custom notifier 59 | 60 | You can define your own abandoned cart logic by changing the `notifier_class` configuration 61 | parameter. Here's what an example notifier could look like, if you wanted to call an external API 62 | instead of sending an email: 63 | 64 | ```ruby 65 | module AwesomeStore 66 | class AbandonedCartNotifier < Spree::AbandonedCartNotifier 67 | def call 68 | # Skip notification if this cart was already notified 69 | return if order.abandoned_cart_email_sent_at 70 | 71 | # Run your custom logic 72 | MyApiService.notify_abandoned_cart(order.email) 73 | 74 | # Mark this cart as notified 75 | order.touch :abandoned_cart_email_sent_at 76 | end 77 | end 78 | end 79 | ``` 80 | 81 | ## Testing 82 | 83 | Run the following to automatically build a dummy app and run the tests: 84 | 85 | ```console 86 | $ bundle exec rake 87 | ``` 88 | 89 | ## Contributing 90 | 91 | Bug reports and pull requests are welcome on GitHub at https://github.com/solidusio-contrib/solidus_abandoned_carts. 92 | 93 | ## License 94 | 95 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 96 | -------------------------------------------------------------------------------- /spec/models/spree/order_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Spree::Order do 4 | subject(:order) { create(:order) } 5 | 6 | before do 7 | stub_spree_preferences(SolidusAbandonedCarts::Config, abandoned_timeout: 24.hours) 8 | end 9 | 10 | describe '.abandoned' do 11 | let!(:abandoned_order) do 12 | create( 13 | :order, 14 | updated_at: Time.zone.now - SolidusAbandonedCarts::Config.abandoned_timeout - 1.second, 15 | item_count: 100, 16 | ) 17 | end 18 | 19 | before do 20 | # Not abandoned, with email, with items 21 | create(:order, item_count: 100) 22 | 23 | # Abandoned, with email, no items 24 | create( 25 | :order, 26 | updated_at: Time.zone.now - SolidusAbandonedCarts::Config.abandoned_timeout - 1.second, 27 | item_count: 0, 28 | ) 29 | 30 | # Abandoned, no email, with items 31 | create( 32 | :order, 33 | updated_at: Time.zone.now - SolidusAbandonedCarts::Config.abandoned_timeout - 1.second, 34 | item_count: 100, 35 | ).update!(email: nil) 36 | end 37 | 38 | it 'returns orders that are abandoned, have an email and have items' do 39 | expect(described_class.abandoned).to match_array([abandoned_order]) 40 | end 41 | end 42 | 43 | describe '.abandon_not_notified' do 44 | let!(:first_abandoned_order) do 45 | create( 46 | :order, 47 | updated_at: abandoned_timeout, 48 | item_count: 100, 49 | ) 50 | end 51 | 52 | let!(:second_abandoned_order) do 53 | # Abandoned but too old with retroactivity set 54 | create( 55 | :order, 56 | updated_at: abandoned_timeout - 1.month, 57 | item_count: 100 58 | ) 59 | end 60 | 61 | let(:abandoned_timeout) { Time.current - SolidusAbandonedCarts::Config.abandoned_timeout - 1.second } 62 | 63 | before do 64 | stub_spree_preferences(SolidusAbandonedCarts::Config, abandoned_retroactivity: abandoned_retroactivity) 65 | 66 | # Abandoned but notified 67 | create( 68 | :order, 69 | updated_at: abandoned_timeout, 70 | item_count: 100, 71 | abandoned_cart_email_sent_at: Time.zone.now, 72 | ) 73 | end 74 | 75 | context 'when the retroactivity configuration is set' do 76 | let(:abandoned_retroactivity) { 1.month } 77 | 78 | it 'returns orders that are abandoned and not notified' do 79 | expect(described_class.abandon_not_notified).to match_array([first_abandoned_order]) 80 | end 81 | end 82 | 83 | context 'when the retroactivity configuration is not set' do 84 | let(:abandoned_retroactivity) { nil } 85 | 86 | it 'returns orders that are abandoned and not notified' do 87 | expect(described_class.abandon_not_notified).to match_array([first_abandoned_order, second_abandoned_order]) 88 | end 89 | end 90 | end 91 | 92 | describe '#last_for_user?' do 93 | context 'when user does not have other orders' do 94 | it 'returns true' do 95 | expect(order).to be_last_for_user 96 | end 97 | end 98 | 99 | context 'when user has newer orders' do 100 | before do 101 | create(:order).update!(email: order.email, created_at: order.created_at + 1.minute) 102 | end 103 | 104 | it 'returns false' do 105 | expect(order).not_to be_last_for_user 106 | end 107 | end 108 | end 109 | end 110 | --------------------------------------------------------------------------------