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.
- 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
|