solidus_mailchimp_sync 1.0.0.beta01

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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