solidus_mailchimp_sync 1.0.0.beta01

Sign up to get free protection for your applications and to get access to all the features.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +26 -0
  3. data/README.md +103 -0
  4. data/Rakefile +31 -0
  5. data/app/assets/javascripts/spree/backend/solidus_mailchimp_sync.js +2 -0
  6. data/app/assets/javascripts/spree/frontend/solidus_mailchimp_sync.js +2 -0
  7. data/app/assets/stylesheets/spree/backend/solidus_mailchimp_sync.css +4 -0
  8. data/app/assets/stylesheets/spree/frontend/solidus_mailchimp_sync.css +4 -0
  9. data/app/jobs/solidus_mailchimp_sync/sync_job.rb +15 -0
  10. data/app/models/solidus_mailchimp_sync/error.rb +32 -0
  11. data/app/models/solidus_mailchimp_sync/mailchimp.rb +69 -0
  12. data/app/serializers/solidus_mailchimp_sync/customer_serializer.rb +34 -0
  13. data/app/serializers/solidus_mailchimp_sync/line_item_serializer.rb +26 -0
  14. data/app/serializers/solidus_mailchimp_sync/order_serializer.rb +70 -0
  15. data/app/serializers/solidus_mailchimp_sync/product_serializer.rb +44 -0
  16. data/app/serializers/solidus_mailchimp_sync/variant_serializer.rb +59 -0
  17. data/app/solidus_decorators/spree/image_decorator.rb +16 -0
  18. data/app/solidus_decorators/spree/line_item_decorator.rb +8 -0
  19. data/app/solidus_decorators/spree/order_decorator.rb +8 -0
  20. data/app/solidus_decorators/spree/price_decorator.rb +10 -0
  21. data/app/solidus_decorators/spree/product_decorator.rb +8 -0
  22. data/app/solidus_decorators/spree/user_decorator.rb +10 -0
  23. data/app/solidus_decorators/spree/variant_decorator.rb +8 -0
  24. data/app/synchronizers/solidus_mailchimp_sync/base_synchronizer.rb +74 -0
  25. data/app/synchronizers/solidus_mailchimp_sync/order_synchronizer.rb +108 -0
  26. data/app/synchronizers/solidus_mailchimp_sync/product_synchronizer.rb +32 -0
  27. data/app/synchronizers/solidus_mailchimp_sync/user_synchronizer.rb +44 -0
  28. data/app/synchronizers/solidus_mailchimp_sync/variant_synchronizer.rb +23 -0
  29. data/config/locales/en.yml +5 -0
  30. data/config/routes.rb +3 -0
  31. data/lib/generators/solidus_mailchimp_sync/install/install_generator.rb +27 -0
  32. data/lib/generators/solidus_mailchimp_sync/install/templates/config/initializers/solidus_mailchimp_sync.rb +36 -0
  33. data/lib/solidus_mailchimp_sync.rb +22 -0
  34. data/lib/solidus_mailchimp_sync/engine.rb +20 -0
  35. data/lib/solidus_mailchimp_sync/factories.rb +35 -0
  36. data/lib/solidus_mailchimp_sync/version.rb +3 -0
  37. data/lib/tasks/solidus_mailchimp_sync.rake +66 -0
  38. metadata +304 -0
@@ -0,0 +1,44 @@
1
+ require 'json'
2
+
3
+ module SolidusMailchimpSync
4
+ # Intentionally does not sync images, let variants do that.
5
+ #
6
+ class ProductSerializer
7
+ attr_reader :product
8
+
9
+ def initialize(product)
10
+ @product = product
11
+ unless product.persisted?
12
+ raise ArgumentError, "Can't serialize a non-saved product: #{product}"
13
+ end
14
+ end
15
+
16
+ # We don't include image or url, variants can have those anyway,
17
+ # and variants are actually editable, do it there.
18
+ def as_json
19
+ hash = {
20
+ id: product.id.to_s,
21
+ handle: product.slug,
22
+ title: product.name,
23
+ description: product.description,
24
+ variants: variants_json
25
+ }
26
+
27
+ if product.available_on
28
+ hash[:published_at_foreign] = product.available_on.iso8601
29
+ end
30
+
31
+ hash
32
+ end
33
+
34
+ def variants_json
35
+ product.variants_including_master.collect do |variant|
36
+ VariantSynchronizer.new(variant).serializer.as_json
37
+ end
38
+ end
39
+
40
+ def to_json
41
+ JSON.dump(as_json)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,59 @@
1
+ require 'json'
2
+
3
+ module SolidusMailchimpSync
4
+ class VariantSerializer
5
+ attr_reader :variant
6
+
7
+ def initialize(variant)
8
+ @variant = variant
9
+ unless variant.persisted?
10
+ raise ArgumentError, "Can't serialize a non-saved variant: #{variant}"
11
+ end
12
+ end
13
+
14
+ def as_json
15
+ hash = {
16
+ id: variant.id.to_s,
17
+ title: title,
18
+ sku: variant.sku,
19
+ price: variant.price.to_f,
20
+ # visibility is a string
21
+ visibility: (visibility || '').to_s
22
+ }
23
+
24
+ url = self.url
25
+ hash[:url] = url if url
26
+
27
+ image_url = self.image_url
28
+ hash[:image_url] = image_url if image_url
29
+
30
+ hash
31
+ end
32
+
33
+ def to_json
34
+ JSON.dump(as_json)
35
+ end
36
+
37
+ def title
38
+ [variant.product.name, variant.options_text].delete_if { |a| a.blank? }.join(' ')
39
+ end
40
+
41
+ # Override in custom serializer for custom front-end url
42
+ def url
43
+ if Rails.application.routes.default_url_options[:host] && Spree::Core::Engine.routes.url_helpers.respond_to?(:product_url)
44
+ Spree::Core::Engine.routes.url_helpers.product_url(variant.product, host: Rails.application.routes.default_url_options[:host])
45
+ end
46
+ end
47
+
48
+ # Override in custom serializer if you want to choose which image different than `first`
49
+ def image_url
50
+ (variant.images.first || variant.product.images.first).try(:url)
51
+ end
52
+
53
+ # Override for custom visibility. Mailchimp wants a string for some reason,
54
+ # not entirely sure what the string should be.
55
+ def visibility
56
+ variant.product.available?.to_s
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,16 @@
1
+ Spree::Image.class_eval do
2
+ after_commit :mailchimp_sync
3
+
4
+ private
5
+ def mailchimp_sync
6
+ if self.viewable && self.viewable.is_a?(Spree::Variant)
7
+ if self.viewable.is_master?
8
+ # Need to sync all variants.
9
+ SolidusMailchimpSync::ProductSynchronizer.new(self.viewable.product).auto_sync(force: true)
10
+ else
11
+ # image just on this variant, just need to sync this one.
12
+ SolidusMailchimpSync::VariantSynchronizer.new(self.variant).auto_sync(force: true)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,8 @@
1
+ Spree::LineItem.class_eval do
2
+ after_commit :mailchimp_sync
3
+
4
+ def mailchimp_sync
5
+ # If a LineItem changes, tell the order to Sync for sure.
6
+ SolidusMailchimpSync::OrderSynchronizer.new(order).auto_sync(force: true)
7
+ end
8
+ end
@@ -0,0 +1,8 @@
1
+ Spree::Order.class_eval do
2
+ after_commit :mailchimp_sync
3
+
4
+ private
5
+ def mailchimp_sync
6
+ SolidusMailchimpSync::OrderSynchronizer.new(self).auto_sync
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ Spree::Price.class_eval do
2
+ after_commit :mailchimp_sync
3
+
4
+ private
5
+ def mailchimp_sync
6
+ if self.variant
7
+ SolidusMailchimpSync::VariantSynchronizer.new(self.variant).auto_sync(force: true)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ Spree::Product.class_eval do
2
+ after_commit :mailchimp_sync
3
+
4
+ private
5
+ def mailchimp_sync
6
+ SolidusMailchimpSync::ProductSynchronizer.new(self).auto_sync
7
+ end
8
+ end
@@ -0,0 +1,10 @@
1
+ if Spree.user_class
2
+ Spree.user_class.class_eval do
3
+ after_commit :mailchimp_sync
4
+
5
+ private
6
+ def mailchimp_sync
7
+ SolidusMailchimpSync::UserSynchronizer.new(self).auto_sync
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,8 @@
1
+ Spree::Variant.class_eval do
2
+ after_commit :mailchimp_sync
3
+
4
+ private
5
+ def mailchimp_sync
6
+ SolidusMailchimpSync::VariantSynchronizer.new(self).auto_sync
7
+ end
8
+ end
@@ -0,0 +1,74 @@
1
+ module SolidusMailchimpSync
2
+ # Individual synchronizers will implement a #sync method, which will be run
3
+ # under an ActiveJob so won't have access to previous state.
4
+ #
5
+ # Individual synchornizers also set serializer_class_name, and synced_attributes,
6
+ # and override #path with Mailchimp API path. May also override other methods.
7
+ class BaseSynchronizer
8
+ class_attribute :synced_attributes
9
+ class_attribute :serializer_class_name
10
+
11
+ attr_reader :model
12
+
13
+ def initialize(model)
14
+ @model = model
15
+ end
16
+
17
+ def path
18
+ raise TypeError, "Sub-class of BaseSynchronizer must implement #path with Solidus ecommerce API path"
19
+ end
20
+
21
+ # Designed to be called in an after commit hook, syncs in bg, if
22
+ # neccessary.
23
+ def auto_sync(force: false)
24
+ if SolidusMailchimpSync.auto_sync_enabled && (force || should_sync?)
25
+ SolidusMailchimpSync::SyncJob.perform_later(self.class.name, model)
26
+ end
27
+ end
28
+
29
+ # in an after-commit situation, we can tell new record from id changed
30
+ def was_newly_inserted?
31
+ pk_changed = model.previous_changes[model.class.primary_key]
32
+
33
+ pk_changed && pk_changed.first.nil? && pk_changed.second.present?
34
+ end
35
+
36
+ def should_sync?
37
+ was_newly_inserted? || synced_attributes_changed?
38
+ end
39
+
40
+ # This gets a lot harder when associations are involved, haven't completely solved it.
41
+ def synced_attributes_changed?
42
+ (model.previous_changes.keys & synced_attributes).present?
43
+ end
44
+
45
+ def put(arg_path = path)
46
+ Mailchimp.ecommerce_request(:put, arg_path, body: serializer.as_json)
47
+ end
48
+
49
+ def delete(arg_path = path, return_errors: false, ignore_404: false)
50
+ Mailchimp.ecommerce_request(:delete, arg_path, return_errors: return_errors)
51
+ rescue SolidusMailchimpSync::Error => e
52
+ if ignore_404 && e.status == 404
53
+ return nil
54
+ end
55
+ raise e
56
+ end
57
+
58
+ def post(arg_create_path = create_path)
59
+ Mailchimp.ecommerce_request(:post, arg_create_path, body: serializer.as_json)
60
+ end
61
+
62
+ def get(arg_path = path)
63
+ Mailchimp.ecommerce_request(:get, arg_path, body: serializer.as_json)
64
+ end
65
+
66
+ def patch(arg_path = path)
67
+ Mailchimp.ecommerce_request(:patch, arg_path, body: serializer.as_json)
68
+ end
69
+
70
+ def serializer
71
+ @serializer ||= serializer_class_name.constantize.new(model)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,108 @@
1
+ module SolidusMailchimpSync
2
+ # Syncs solidus orders to mailchimp Carts and Orders, adding/deleting
3
+ # as cart status changes.
4
+ class OrderSynchronizer < BaseSynchronizer
5
+ self.serializer_class_name = "::SolidusMailchimpSync::OrderSerializer"
6
+ # We update on all state changes, even though some might not really
7
+ # require a mailchimp sync, it just depends on what we're serializing.
8
+ # Also, when solidus sets completed_at, it seems not to trigger
9
+ # an after_commit, so we can't catch transition to complete that way.
10
+ #
11
+ # We used to update on changes to any totals thinking we could catch
12
+ # line item changes that way -- but removing a line item and adding
13
+ # another with the exact same price wouldn't trigger total changes, so
14
+ # we had to trap all line item changes instead on a LineItem decorator,
15
+ # so sync'ing on changes to order totals isn't neccesary just causes
16
+ # extra superfluous syncs.
17
+ self.synced_attributes = %w{state}
18
+
19
+ class_attribute :line_item_serializer_class_name
20
+ self.line_item_serializer_class_name = "::SolidusMailchimpSync::LineItemSerializer"
21
+
22
+ def path
23
+ if order_complete?
24
+ order_path
25
+ else
26
+ cart_path
27
+ end
28
+ end
29
+
30
+ def create_path
31
+ if order_complete?
32
+ create_order_path
33
+ else
34
+ create_cart_path
35
+ end
36
+ end
37
+
38
+ def sync
39
+ # If we don't have a user with an email address we can't sync -- mailchimp
40
+ # carts and orders require a customer, which requires an email address.
41
+ unless model.user.present? && model.user.send(UserSynchronizer.email_address_attribute).present?
42
+ return nil
43
+ end
44
+
45
+ # Can't sync an empty cart to mailchimp, delete the cart/order if
46
+ # we've previously synced, and bail out of this sync.
47
+ if model.line_items.empty?
48
+ return delete(path, ignore_404: true)
49
+ end
50
+
51
+ # if it's a completed order, delete any previous synced _cart_ version
52
+ # of this order, if one is present. There should be no Mailchimp 'cart',
53
+ # only a mailchimp 'order' now.
54
+ #byebug
55
+ if order_complete?
56
+ delete(cart_path, ignore_404: true)
57
+ end
58
+
59
+ post_or_patch(post_path: create_path, patch_path: path)
60
+ rescue SolidusMailchimpSync::Error => e
61
+ tries ||= 0 ; tries += 1
62
+ if tries <= 1 && user_not_synced_error?(e)
63
+ SolidusMailchimpSync::UserSynchronizer.new(model.user).sync
64
+ retry
65
+ else
66
+ raise e
67
+ end
68
+ end
69
+
70
+ def order_complete?
71
+ # Yes, somehow solidus can sometimes, temporarily, in our after commit hook
72
+ # have state==complete set, but not completed_at
73
+ model.completed? || model.state == "complete"
74
+ end
75
+
76
+ def post_or_patch(post_path:, patch_path:)
77
+ post(post_path)
78
+ rescue SolidusMailchimpSync::Error => e
79
+ if e.status == 400 && e.detail =~ /already exists/
80
+ patch(patch_path)
81
+ else
82
+ raise e
83
+ end
84
+ end
85
+
86
+ def user_not_synced_error?(e)
87
+ e.status == 400 &&
88
+ e.response_hash["errors"].present? &&
89
+ e.response_hash["errors"].any? { |h| %w{customer.email_address customer.opt_in_status}.include? h["field"] }
90
+ end
91
+
92
+ def cart_path
93
+ "/carts/#{model.id}"
94
+ end
95
+
96
+ def create_cart_path
97
+ "/carts"
98
+ end
99
+
100
+ def order_path
101
+ "/orders/#{model.id}"
102
+ end
103
+
104
+ def create_order_path
105
+ "/orders"
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,32 @@
1
+ module SolidusMailchimpSync
2
+ class ProductSynchronizer < BaseSynchronizer
3
+ self.serializer_class_name = "::SolidusMailchimpSync::ProductSerializer"
4
+ self.synced_attributes = %w{name description slug available_on}
5
+
6
+ def sync
7
+ # We go ahead and try to create it. If it already existed, mailchimp
8
+ # doesn't let us do an update, but we can update all variants.
9
+ post
10
+ rescue SolidusMailchimpSync::Error => e
11
+ if e.status == 400 && e.detail =~ /already exists/
12
+ sync_all_variants
13
+ else
14
+ raise e
15
+ end
16
+ end
17
+
18
+ def path
19
+ "/products/#{product_id}"
20
+ end
21
+
22
+ def create_path
23
+ "/products"
24
+ end
25
+
26
+ def sync_all_variants
27
+ model.variants_including_master.collect do |variant|
28
+ VariantSynchronizer.new(variant).sync
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,44 @@
1
+ require 'cgi'
2
+
3
+ module SolidusMailchimpSync
4
+ class UserSynchronizer < BaseSynchronizer
5
+ self.serializer_class_name = "::SolidusMailchimpSync::CustomerSerializer"
6
+ self.synced_attributes = ['email']
7
+
8
+ class_attribute :email_address_attribute
9
+ self.email_address_attribute = :email
10
+
11
+ # synchronizers local user to Mailchimp customer. Whether Customer
12
+ # already existed on mailchimp end or not. Deletes if deleted.
13
+ def sync
14
+ if model.send(email_address_attribute).blank?
15
+ # can't sync a user without an email address, mailchimp doesn't allow it
16
+ return nil
17
+ end
18
+
19
+ if model.deleted?
20
+ delete
21
+ else
22
+ put
23
+ end
24
+ end
25
+
26
+ def path
27
+ "/customers/#{CGI.escape mailchimp_id}"
28
+ end
29
+
30
+ def mailchimp_id
31
+ self.class.customer_id(model)
32
+ end
33
+
34
+ # ID used to identify a user on mailchimp. Mailchimp does not
35
+ # allow ID's to change, even though we do, so care is needed.
36
+ # '@' sign in ID seems to maybe confuse mailchimp.
37
+ def self.customer_id(user)
38
+ email = user.send(email_address_attribute)
39
+ email = email && email.gsub("@", "-at-")
40
+ "#{user.id}-#{email}"
41
+ end
42
+
43
+ end
44
+ end