solidus_mailchimp_sync 1.0.0.beta01
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +26 -0
- data/README.md +103 -0
- data/Rakefile +31 -0
- data/app/assets/javascripts/spree/backend/solidus_mailchimp_sync.js +2 -0
- data/app/assets/javascripts/spree/frontend/solidus_mailchimp_sync.js +2 -0
- data/app/assets/stylesheets/spree/backend/solidus_mailchimp_sync.css +4 -0
- data/app/assets/stylesheets/spree/frontend/solidus_mailchimp_sync.css +4 -0
- data/app/jobs/solidus_mailchimp_sync/sync_job.rb +15 -0
- data/app/models/solidus_mailchimp_sync/error.rb +32 -0
- data/app/models/solidus_mailchimp_sync/mailchimp.rb +69 -0
- data/app/serializers/solidus_mailchimp_sync/customer_serializer.rb +34 -0
- data/app/serializers/solidus_mailchimp_sync/line_item_serializer.rb +26 -0
- data/app/serializers/solidus_mailchimp_sync/order_serializer.rb +70 -0
- data/app/serializers/solidus_mailchimp_sync/product_serializer.rb +44 -0
- data/app/serializers/solidus_mailchimp_sync/variant_serializer.rb +59 -0
- data/app/solidus_decorators/spree/image_decorator.rb +16 -0
- data/app/solidus_decorators/spree/line_item_decorator.rb +8 -0
- data/app/solidus_decorators/spree/order_decorator.rb +8 -0
- data/app/solidus_decorators/spree/price_decorator.rb +10 -0
- data/app/solidus_decorators/spree/product_decorator.rb +8 -0
- data/app/solidus_decorators/spree/user_decorator.rb +10 -0
- data/app/solidus_decorators/spree/variant_decorator.rb +8 -0
- data/app/synchronizers/solidus_mailchimp_sync/base_synchronizer.rb +74 -0
- data/app/synchronizers/solidus_mailchimp_sync/order_synchronizer.rb +108 -0
- data/app/synchronizers/solidus_mailchimp_sync/product_synchronizer.rb +32 -0
- data/app/synchronizers/solidus_mailchimp_sync/user_synchronizer.rb +44 -0
- data/app/synchronizers/solidus_mailchimp_sync/variant_synchronizer.rb +23 -0
- data/config/locales/en.yml +5 -0
- data/config/routes.rb +3 -0
- data/lib/generators/solidus_mailchimp_sync/install/install_generator.rb +27 -0
- data/lib/generators/solidus_mailchimp_sync/install/templates/config/initializers/solidus_mailchimp_sync.rb +36 -0
- data/lib/solidus_mailchimp_sync.rb +22 -0
- data/lib/solidus_mailchimp_sync/engine.rb +20 -0
- data/lib/solidus_mailchimp_sync/factories.rb +35 -0
- data/lib/solidus_mailchimp_sync/version.rb +3 -0
- data/lib/tasks/solidus_mailchimp_sync.rake +66 -0
- 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,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
|