From ed0e4daf68df15e7705a490008dad5b2fa22eac5 Mon Sep 17 00:00:00 2001 From: Ryan Kulp Date: Wed, 9 Jul 2025 09:23:00 -0400 Subject: [PATCH] Shopify --- README.md | 1 + lib/shopify/shopify.rb | 180 ++++++++++++++++++++ lib/shopify/views/full.html.erb | 184 +++++++++++++++++++++ lib/shopify/views/half_horizontal.html.erb | 178 ++++++++++++++++++++ lib/shopify/views/half_vertical.html.erb | 177 ++++++++++++++++++++ lib/shopify/views/quadrant.html.erb | 134 +++++++++++++++ 6 files changed, 854 insertions(+) create mode 100644 lib/shopify/shopify.rb create mode 100644 lib/shopify/views/full.html.erb create mode 100644 lib/shopify/views/half_horizontal.html.erb create mode 100644 lib/shopify/views/half_vertical.html.erb create mode 100644 lib/shopify/views/quadrant.html.erb diff --git a/README.md b/README.md index d217cf8..04c068c 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ community Recipes: - [Hacker News](/lib/hacker_news) - [Lunar Calendar](/lib/lunar_calendar) - [Lunch Money](/lib/lunch_money) +- [Shopify](/lib/shopify) - [Stock Price](/lib/stock_price) - [Tempest Weather Station](/lib/tempest_weather_station) - [Todoist](/lib/todoist) diff --git a/lib/shopify/shopify.rb b/lib/shopify/shopify.rb new file mode 100644 index 0000000..275459f --- /dev/null +++ b/lib/shopify/shopify.rb @@ -0,0 +1,180 @@ +module Plugins + class Shopify < Base + + def locals + { metrics:, histogram:, currency:, comparison_period: comparison_period.map { it.to_date.to_s } } + end + + private + + def metrics + kpi = {} + kpi[:open_order_count] = fetch_order_count('open') + kpi[:closed_order_count] = fetch_order_count('closed') + + total_orders = kpi[:open_order_count] + kpi[:closed_order_count] + + if total_orders.positive? + fetch_orders + kpi[:order_sales] = calculate_sale_value || 0 + kpi[:aov] = (kpi[:order_sales] / (kpi[:open_order_count] + kpi[:closed_order_count])).round(2) + end + + kpi + rescue InvalidURL + { errors: "Invalid Store Web Address or Access Token" } + end + + def fetch_orders + @orders ||= query_sales_orders(lookback_period.to_s, nil) + @comparison_orders ||= query_sales_orders(*comparison_period) + end + + def histogram + return { current: [], comparison: [] } if @orders.nil? + + current = prepare_charting_data(@orders) + comparison = prepare_charting_data(@comparison_orders) + comparison = normalize_with_current(comparison, current) + + { current:, comparison: } + end + + def prepare_charting_data(orders) + return [] if orders.nil? || orders.empty? + + order_data = orders&.map { { date: Date.parse(it['created_at']).to_s, value: it['current_subtotal_price'].to_f } } + &.group_by { it[:date] } + &.map { |k, v| { date: k, value: v.sum { it[:value] }.round(2) } } + + dates = order_data.map { it[:date] } + + min_date = Date.parse(dates.min) + max_date = Date.parse(dates.max) + + all_dates_hash = (min_date..max_date).each_with_object({}) do |date, hash| + hash[date.to_s] = { date: date.to_s, value: 0.0 } + end + + order_data.each do |order| + all_dates_hash[order[:date]] = order + end + + all_dates_hash.values.sort_by { it[:date] } + end + + def normalize_with_current(comparison, current) + normalized_data = [] + case settings['lookback_period'] + when 'last_7_days' + current.each_with_index do |data, index| + next if comparison[index].nil? + + normalized_data.push({ date: data[:date], value: comparison[index][:value] }) + end + else + current.each do |data| + selected_date = comparison.select { (Date.parse(it[:date]) + 1.month).to_s == data[:date] }.first + next unless selected_date + + normalized_data.push({ date: data[:date], value: selected_date[:value] }) + end + end + + normalized_data + end + + def calculate_sale_value + @orders&.sum { |o| o['current_subtotal_price'].to_f } + end + + def lookback_period + case settings['lookback_period'] + when 'last_7_days' + (user.datetime_now - 7.days).iso8601 + when 'last_30_days' + (user.datetime_now - 30.days).iso8601 + when 'month_to_date' + user.datetime_now.beginning_of_month.iso8601 + end + end + + def comparison_period + case settings['lookback_period'] + when 'last_7_days' + [(user.datetime_now - 14.days).iso8601, (user.datetime_now - 7.days).iso8601] + when 'last_30_days' + [(user.datetime_now - 60.days).iso8601, (user.datetime_now - 30.days).iso8601] + when 'month_to_date' + [(user.datetime_now.beginning_of_month - 1.month).iso8601, (user.datetime_now - 1.month).iso8601] + end + end + + def fetch_order_count(status = 'any') + client.get( + path: 'orders/count', + query: { + status: status, + created_at_min: ">=#{lookback_period}" + } + ).body['count'] + rescue ShopifyAPI::Errors::HttpResponseError => e + error_message = e.response.body['errors'] + raise InvalidURL if error_message.include?('Not Found') || error_message.include?('Invalid API key or access token') + end + + def query_sales_orders(start_window, end_window) + orders = [] + run_loop = true + + while run_loop + retrieved_orders = client.get( + path: 'orders', + query: { + status: 'any', + limit: 250, + created_at_min: start_window, + processed_at_max: orders&.last&.dig('created_at') || end_window + } + ).body['orders'] + run_loop = false if retrieved_orders.count < 250 + orders += retrieved_orders.reject { |m| m['cancelled_at'].present? } + end + + orders + end + + def currency + code = client.get(path: 'shop').body['shop']['currency'] + + { + 'USD' => '$', + 'EUR' => '€', + 'CAD' => '$', + 'CHF' => '₣', + 'GBP' => '£', + 'KRW' => '₩', + 'CNY' => '¥', + 'JPY' => '¥', + 'INR' => '₹', + 'ZAR' => 'R' + }[code] || '$' + rescue ShopifyAPI::Errors::HttpResponseError => e + error_message = e.response.body['errors'] + raise InvalidURL if error_message.include?('Not Found') || error_message.include?('Invalid API key or access token') + end + + def client = ShopifyAPI::Clients::Rest::Admin.new(session:) + + def session = ShopifyAPI::Auth::Session.new(shop:, access_token:) + + def myshopify_domain + shop = settings['shop'] + shop.include?('myshopify') ? shop.split('.')[0] : shop + end + + def shop = "#{myshopify_domain.strip}.myshopify.com" + + def access_token = settings['access_token'] + end +end diff --git a/lib/shopify/views/full.html.erb b/lib/shopify/views/full.html.erb new file mode 100644 index 0000000..1b3b8c2 --- /dev/null +++ b/lib/shopify/views/full.html.erb @@ -0,0 +1,184 @@ +<% unless (metrics[:errors].present?) %> +
+
+
+
+
+
+
+
+ <%= number_to_currency(metrics[:order_sales], unit: currency, precision: 0) %> + Total Sales +
+
+ +
+
+
+ <%= number_with_delimiter(metrics[:open_order_count]) %> + Pending Orders +
+
+ +
+
+
+ +
+ <% if histogram[:current].present? %> + <%= DateTime.parse(histogram[:current].first[:date]).strftime("%b %d") %> - + <%= DateTime.parse(histogram[:current].last[:date]).strftime("%b %d") %> + <% end %> +
+ <%= histogram.first[0].capitalize %> +
+
+
+
+
+ +
+ +
+
+
+
+
+
+ <%= number_to_currency(metrics[:aov], unit: currency, precision: 0) %> + AOV +
+
+ +
+
+
+ <%= number_with_delimiter(metrics[:closed_order_count]) %> + Fulfilled Orders +
+
+ +
+
+
+ +
+ <%= DateTime.parse(comparison_period.first).strftime("%b %d") %> - + <%= DateTime.parse(comparison_period.last).strftime("%b %d") %> +
+ <%= histogram.to_a.last[0].capitalize %> +
+
+
+
+
+ + <%= render 'plugins/chart_js', libs: [:highcharts, :highcharts_more, :highcharts_pattern_fill] %> + + <% formatted_data = histogram.map { |key, period| { name: key.capitalize, data: period.map { [it[:date], it[:value]] }}} %> + <% chart_id = "chart-#{SecureRandom.hex(6)}" %> +
+ + +
+ +
+ + Shopify + <%= instance_name %> +
+
+<% else %> + <%= render partial: 'plugins/errors/full', locals: { + icon: 'shopify--render.svg', + title: 'Shopify', + instance: instance_name, + error_message: metrics[:errors] + } + %> +<% end %> diff --git a/lib/shopify/views/half_horizontal.html.erb b/lib/shopify/views/half_horizontal.html.erb new file mode 100644 index 0000000..91b512f --- /dev/null +++ b/lib/shopify/views/half_horizontal.html.erb @@ -0,0 +1,178 @@ +<% unless (metrics[:errors].present?) %> +
+
+
+
+
+
+
+
+ <%= number_to_currency(metrics[:order_sales], unit: currency, precision: 0) %> + Total Sales +
+
+
+
+
+ <%= number_to_currency(metrics[:aov], unit: currency, precision: 0) %> + AOV +
+
+
+
+
+
+
+
+ <%= number_with_delimiter(metrics[:open_order_count]) %> + Pending Orders +
+
+
+
+
+ <%= number_with_delimiter(metrics[:closed_order_count]) %> + Fulfilled Orders +
+
+
+
+
+
+
+
+ +
+ <%= DateTime.parse(comparison_period.first).strftime("%b %d") %> - + <%= DateTime.parse(comparison_period.last).strftime("%b %d") %> +
+
+
+
+
+
+ +
+ <% if histogram[:current].present? %> + <%= DateTime.parse(histogram[:current].first[:date]).strftime("%b %d") %> - + <%= DateTime.parse(histogram[:current].last[:date]).strftime("%b %d") %> + <% end %> +
+
+
+
+
+
+ <% formatted_data = histogram.map { |key, period| { name: key.capitalize, data: period.map { [it[:date], it[:value]] }}} %> + <% chart_id = "chart-#{SecureRandom.hex(6)}" %> +
+ + <%= render 'plugins/chart_js', libs: [:highcharts, :highcharts_more, :highcharts_pattern_fill] %> + + +
+
+
+ +
+ + Shopify + <%= instance_name %> +
+
+<% else %> + <%= render partial: 'plugins/errors/half_horizontal', locals: { + icon: 'shopify--render.svg', + title: 'Shopify', + instance: instance_name, + error_message: metrics[:errors] + } %> +<% end %> diff --git a/lib/shopify/views/half_vertical.html.erb b/lib/shopify/views/half_vertical.html.erb new file mode 100644 index 0000000..a83b597 --- /dev/null +++ b/lib/shopify/views/half_vertical.html.erb @@ -0,0 +1,177 @@ +<% unless (metrics[:errors].present?) %> +
+
+
+
+
+
+ <%= number_to_currency(metrics[:order_sales], unit: currency, precision: 0) %> + Total Sales +
+
+
+
+
+ <%= number_to_currency(metrics[:aov], unit: currency, precision: 0) %> + AOV +
+
+
+ +
+ +
+
+
+
+ <%= number_with_delimiter(metrics[:open_order_count]) %> + Pending Orders +
+
+
+
+
+ <%= number_with_delimiter(metrics[:closed_order_count]) %> + Fulfilled Orders +
+
+
+ +
+ +
+
+
+
+ +
+ <%= DateTime.parse(comparison_period.first).strftime("%b %d") %> - + <%= DateTime.parse(comparison_period.last).strftime("%b %d") %> +
+
+
+
+
+
+ +
+ <% if histogram[:current].present? %> + <%= DateTime.parse(histogram[:current].first[:date]).strftime("%b %d") %> - + <%= DateTime.parse(histogram[:current].last[:date]).strftime("%b %d") %> + <% end %> +
+
+
+
+ + <% formatted_data = histogram.map { |key, period| { name: key.capitalize, data: period.map { [it[:date], it[:value]] }}} %> + <% chart_id = "chart-#{SecureRandom.hex(6)}" %> +
+ + <%= render 'plugins/chart_js', libs: [:highcharts, :highcharts_more, :highcharts_pattern_fill] %> + + +
+ +
+ + Shopify + <%= instance_name %> +
+
+<% else %> + <%= render partial: 'plugins/errors/half_vertical', locals: { + icon: 'shopify--render.svg', + title: 'Shopify', + instance: instance_name, + error_message: metrics[:errors] + } %> +<% end %> diff --git a/lib/shopify/views/quadrant.html.erb b/lib/shopify/views/quadrant.html.erb new file mode 100644 index 0000000..520768b --- /dev/null +++ b/lib/shopify/views/quadrant.html.erb @@ -0,0 +1,134 @@ +<% unless (metrics[:errors].present?) %> +
+
+
+
+
+
+ <%= number_to_currency(metrics[:order_sales], unit: currency, precision: 0) %> + Total Sales +
+
+
+ <% formatted_data = histogram.map { |key, period| { name: key.capitalize, data: period.map { [it[:date], it[:value]] }}} %> + <% chart_id = "chart-#{SecureRandom.hex(6)}" %> + +
+ + <%= render 'plugins/chart_js', libs: [:highcharts, :highcharts_more, :highcharts_pattern_fill] %> + + +
+
+
+
+
+
+
+ <%= number_with_delimiter(metrics[:open_order_count]) %> + Pending Orders +
+
+
+
+
+ <%= number_with_delimiter(metrics[:closed_order_count]) %> + Fulfilled Orders +
+
+
+
+
+
+
+
+ +
+ <%= DateTime.parse(comparison_period.first).strftime("%b %d") %> - + <%= DateTime.parse(comparison_period.last).strftime("%b %d") %> +
+
+
+
+
+
+ +
+ <%= DateTime.parse(histogram[:current].first[:date]).strftime("%b %d") %> - + <%= DateTime.parse(histogram[:current].last[:date]).strftime("%b %d") %> +
+
+
+
+
+ +
+ + Shopify + <%= instance_name %> +
+
+<% else %> + <%= render partial: 'plugins/errors/quadrant', locals: { + icon: 'shopify--render.svg', + title: 'Shopify', + instance: instance_name, + error_message: metrics[:errors] + } %> +<% end %>