diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..be38a21 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,19 @@ +name: Tests + +on: push + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run tests + run: | + bundle exec ruby test/*_test.rb diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..fa7adc7 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.3.5 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..e2d2b50 --- /dev/null +++ b/Gemfile @@ -0,0 +1,17 @@ +source "https://rubygems.org" + +ruby file: ".ruby-version" + +gem "sinatra", github: "sinatra", require: "sinatra/base" +gem "rackup" +gem "puma" + +gem "activesupport", require: "active_support/all" +gem "logger" +gem "json" + +group :test do + gem "minitest" + gem "rack-test" +end + diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..adf37c5 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,79 @@ +GIT + remote: https://github.com/sinatra/sinatra.git + revision: 973c936319af9132d7ab2f60985e359d0c75c93e + specs: + rack-protection (4.0.0) + base64 (>= 0.1.0) + logger (>= 1.6.0) + rack (>= 3.0.0, < 4) + sinatra (4.0.0) + logger (>= 1.6.0) + mustermann (~> 3.0) + rack (>= 3.0.0, < 4) + rack-protection (= 4.0.0) + rack-session (>= 2.0.0, < 3) + tilt (~> 2.0) + +GEM + remote: https://rubygems.org/ + specs: + activesupport (7.2.1) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + base64 (0.2.0) + bigdecimal (3.1.8) + concurrent-ruby (1.3.4) + connection_pool (2.4.1) + drb (2.2.1) + i18n (1.14.5) + concurrent-ruby (~> 1.0) + json (2.7.2) + logger (1.6.1) + minitest (5.25.1) + mustermann (3.0.3) + ruby2_keywords (~> 0.0.1) + nio4r (2.7.3) + puma (6.4.2) + nio4r (~> 2.0) + rack (3.1.7) + rack-session (2.0.0) + rack (>= 3.0.0) + rack-test (2.1.0) + rack (>= 1.3) + rackup (2.1.0) + rack (>= 3) + webrick (~> 1.8) + ruby2_keywords (0.0.5) + securerandom (0.3.1) + tilt (2.4.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + webrick (1.8.1) + +PLATFORMS + arm64-darwin-23 + ruby + +DEPENDENCIES + activesupport + json + logger + minitest + puma + rack-test + rackup + sinatra! + +RUBY VERSION + ruby 3.3.5p100 + +BUNDLED WITH + 2.5.18 diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..4fd3163 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: bundle exec puma -p $PORT diff --git a/README.md b/README.md index 173b2ea..7790c84 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,22 @@ -# locali-ge.csa-admin -Small Sinatra app to handle locali-ge.ch woocommerce webhooks +# locali-ge.csa-admin.org + +A small Sinatra app to handle locali-ge.ch WooCommerce webhooks and automatically create a new member in the CSA Admin organization. + +The mapping of the WooCommerce products to the CSA Admin organization resources is handled in the `config/mapping.yml` file. The `api_endpoint` of the CSA Admin API must be set in the `config/config.yml` file for each organization. + +## Deployment + +The `WEBHOOK_SECRET` environment variable must be set to the WooCommerce webhook secret. + +For each organization, the `_API_TOKEN` environment variable must be set. + +## Testing + +```sh +bundle install +bundle exec ruby test/*_test.rb +``` + +## Author + +[Thibaud Guillaume-Gentil](https://thibaud.gg) diff --git a/app.rb b/app.rb new file mode 100644 index 0000000..5512f4a --- /dev/null +++ b/app.rb @@ -0,0 +1,49 @@ +require 'bundler/setup' +Bundler.require(:default) + +require_relative 'lib/webhook' + +class App < Sinatra::Base + configure :production, :development do + enable :logging + end + + before do + @request_body = request.body.read + end + + post '/webhook' do + verify_signature! + payload = parse_payload + + logger.info payload + begin + member_params = Webhook.handle!(payload) + logger.info member_params + rescue ArgumentError => e + logger.info e.message + end + + status 204 + end + + private + + def verify_signature! + signature = request.env['HTTP_X_WC_WEBHOOK_SIGNATURE'] + secret = ENV['WEBHOOK_SECRET'] + + computed_hmac = Base64.strict_encode64( + OpenSSL::HMAC.digest('sha256', secret, @request_body)) + + unless signature && Rack::Utils.secure_compare(computed_hmac, signature) + halt 403, 'Forbidden' + end + end + + def parse_payload + JSON.parse(@request_body) + rescue JSON::ParserError + halt 400, 'Invalid JSON' + end +end diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..5b106e2 --- /dev/null +++ b/config.ru @@ -0,0 +1,2 @@ +require './app' +run App diff --git a/config/mapping.yml b/config/mapping.yml new file mode 100644 index 0000000..af27c77 --- /dev/null +++ b/config/mapping.yml @@ -0,0 +1,14 @@ +cocagne: + store_id: 31 + api_endpoint: https://admin.cocagne.ch/api/v1/members + basket_sizes: + 35087: 1 # Grande part + basket_complements: + 34780: 9 # Oeufs 6 pièces + 34781: 10 # Pain Cora blé 500g + 34782: 12 # Pain Cora épeautre 450g + 34783: 6 # Fromage frais + 34787: 14 # Jus de pomme regarge + depots: + 34088: 43 # 16 ch. Franconis 1290 Versoix + 34087: 22 # 15 ch. de Roches 1208 Genève diff --git a/lib/webhook.rb b/lib/webhook.rb new file mode 100644 index 0000000..71a7b26 --- /dev/null +++ b/lib/webhook.rb @@ -0,0 +1,98 @@ +require 'yaml' + +class Webhook + attr_reader :payload + + def self.handle!(payload) + new(payload).handle! + end + + def initialize(payload) + @payload = payload + end + + def handle! + ensure_mapping! + + member_params + end + + private + + def ensure_mapping! + return if mapping + + store_name = @payload.dig("store", "name") + raise ArgumentError, "No mapping found for store: #{store_id} (#{store_name})" + end + + def member_params + { + organization: organization, + name: "#{billing["last_name"]} #{billing["first_name"]}", + emails: billing["email"], + phones: billing["phone"], + address: [billing["address_1"], billing["address_2"]].map(&:presence).compact.join(', '), + city: billing["city"], + zip: billing["postcode"], + country_code: billing["country"], + waiting_basket_size_id: mapping_id_for("basket_sizes"), + waiting_depot_id: mapping_id_for("depots"), + members_basket_complements_attributes: basket_complements + } + end + + def mapping + @mapping ||= YAML.load_file('./config/mapping.yml').detect { |name, v| + v['store_id'] == store_id + } + end + + def organization + mapping.first + end + + def basket_complements + mapping_ids_for("basket_complements").map { |id| + { basket_complement_id: id, quanity: 1 } + } + end + + def mapping_id_for(type) + mapping.last[type].each { |product_id, id| + return id if product_id.in?(product_ids) + } + nil + end + + def mapping_ids_for(type) + ids = [] + mapping.last[type].each { |product_id, id| + ids << id if product_id.in?(product_ids) + } + ids + end + + def product_ids + @ids ||= begin + ids =[] + @payload.fetch("line_items").each { |item| + ids << item["product_id"] + item["meta_data"].each { |meta| + if meta["key"] == "selected_item_post_id" + meta["value"].each { |v| v.values.each { |v| ids += Array(v["value"]) } } + end + } + } + ids.map(&:to_i) + end + end + + def billing + @billing ||= @payload.fetch("billing") + end + + def store_id + @payload.dig("store", "id") + end +end diff --git a/test/app_test.rb b/test/app_test.rb new file mode 100644 index 0000000..94fba01 --- /dev/null +++ b/test/app_test.rb @@ -0,0 +1,75 @@ +ENV['RACK_ENV'] = 'test' + +require 'bundler/setup' +Bundler.require(:default, :test) + +require_relative '../app' +require "minitest/autorun" + +class AppTest < Minitest::Test + include Rack::Test::Methods + + def app; App end + + def setup + @secret = 'test_secret' + ENV['WEBHOOK_SECRET'] = @secret + end + + def request(payload, secret: nil) + secret ||= @secret + signature = Base64.strict_encode64( + OpenSSL::HMAC.digest('sha256', secret, payload)) + + header "Content-Type", 'application/json' + header "X-WC-Webhook-Signature", signature + + post "/webhook", payload + end + + def test_valid_webhook_request + payload = File.read('test/fixtures/order_created.json') + + request(payload) + + assert_equal 204, last_response.status + assert_empty last_response.body + end + + def test_unknown_store + payload = { "store" => { "id" => 999, "name" => "Unknown" } }.to_json + + request(payload) + + assert_equal 204, last_response.status + assert_empty last_response.body + end + + def test_invalid_signature + payload = { "test_key" => "test_value" }.to_json + + request(payload, secret: "wrong_secret") + + assert_equal 403, last_response.status + assert_equal "Forbidden", last_response.body + end + + def test_missing_signature_header + payload = { "test_key" => "test_value" }.to_json + + header "Content-Type", 'application/json' + post "/webhook", payload + + assert_equal 403, last_response.status + assert_equal "Forbidden", last_response.body + end + + def test_invalid_json_payload + payload = "invalid_json" + + request(payload) + + assert_equal 400, last_response.status + assert_includes last_response.body, "Invalid JSON" + end +end diff --git a/test/fixtures/order_created.json b/test/fixtures/order_created.json new file mode 100644 index 0000000..7264f34 --- /dev/null +++ b/test/fixtures/order_created.json @@ -0,0 +1,312 @@ +{ + "id": 35255, + "parent_id": 0, + "status": "postfi-redirected", + "currency": "CHF", + "version": "8.7.0", + "prices_include_tax": true, + "date_created": "2024-09-11T12:41:55", + "date_modified": "2024-09-11T12:41:57", + "discount_total": "0.00", + "discount_tax": "0.00", + "shipping_total": "0.00", + "shipping_tax": "0.00", + "cart_tax": "0.00", + "total": "123.00", + "total_tax": "0.00", + "customer_id": 2, + "order_key": "wc_order_SKGPs39HZHZht", + "billing": { + "first_name": "John", + "last_name": "Doe", + "company": "", + "address_1": "Chemin de la Mairie", + "address_2": "1", + "city": "Troinex", + "state": "GE", + "postcode": "1256", + "country": "CH", + "email": "john@doe.ch", + "phone": "079 123 45 67" + }, + "shipping": { + "first_name": "", + "last_name": "", + "company": "", + "address_1": "", + "address_2": "", + "city": "", + "state": "", + "postcode": "", + "country": "", + "phone": "" + }, + "payment_method": "postfinancecheckout_2", + "payment_method_title": "Carte de cr\u00e9dit\/d\u00e9bit", + "transaction_id": "", + "customer_ip_address": "2a02:1210:5c0b:9f00:503e:5681:58f0:7853", + "customer_user_agent": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko\/20100101 Firefox\/129.0", + "created_via": "checkout", + "customer_note": "", + "date_completed": null, + "date_paid": null, + "cart_hash": "b357f9d8c6e80bb0d14c22e50fe49b00", + "number": "35255", + "meta_data": [ + { + "id": 1404, + "key": "_dokan_vendor_id", + "value": "31" + }, + { + "id": 1421, + "key": "_postfinancecheckout_confirmed", + "value": "true" + }, + { + "id": 1420, + "key": "_postfinancecheckout_gateway_id", + "value": "postfinancecheckout_2" + }, + { + "id": 1419, + "key": "_postfinancecheckout_pay_for_order", + "value": "" + }, + { + "id": 1418, + "key": "_wc_order_attribution_device_type", + "value": "Desktop" + }, + { + "id": 1409, + "key": "_wc_order_attribution_referrer", + "value": "https:\/\/mail.infomaniak.com\/" + }, + { + "id": 1416, + "key": "_wc_order_attribution_session_count", + "value": "80" + }, + { + "id": 1413, + "key": "_wc_order_attribution_session_entry", + "value": "https:\/\/locali-ge.ch\/nos-magasins\/margaux\/" + }, + { + "id": 1415, + "key": "_wc_order_attribution_session_pages", + "value": "5" + }, + { + "id": 1414, + "key": "_wc_order_attribution_session_start_time", + "value": "2024-09-09 12:24:20" + }, + { + "id": 1408, + "key": "_wc_order_attribution_source_type", + "value": "referral" + }, + { + "id": 1417, + "key": "_wc_order_attribution_user_agent", + "value": "Mozilla\/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko\/20100101 Firefox\/129.0" + }, + { + "id": 1412, + "key": "_wc_order_attribution_utm_content", + "value": "\/" + }, + { + "id": 1411, + "key": "_wc_order_attribution_utm_medium", + "value": "referral" + }, + { + "id": 1410, + "key": "_wc_order_attribution_utm_source", + "value": "mail.infomaniak.com" + }, + { + "id": 1401, + "key": "is_vat_exempt", + "value": "no" + }, + { + "id": 1405, + "key": "shipping_fee_recipient", + "value": "admin" + }, + { + "id": 1407, + "key": "shipping_tax_fee_recipient", + "value": "admin" + }, + { + "id": 1406, + "key": "tax_fee_recipient", + "value": "admin" + } + ], + "line_items": [ + { + "id": 298, + "name": "Paniers de l\u00e9gumes des Jardins de Cocagne - Offre d\u00e9couverte", + "product_id": 34050, + "variation_id": 0, + "quantity": 1, + "tax_class": "26", + "subtotal": "123.00", + "subtotal_tax": "0.00", + "total": "123.00", + "total_tax": "0.00", + "taxes": [], + "meta_data": [ + { + "id": 3280, + "key": "_postfinancecheckout_unique_line_item_id", + "value": "f5ad50e9-cc02-4994-9431-3af2f82e3197", + "display_key": "_postfinancecheckout_unique_line_item_id", + "display_value": "f5ad50e9-cc02-4994-9431-3af2f82e3197" + }, + { + "id": 3281, + "key": "_postfinancecheckout_coupon_discount_line_item_discounts", + "value": "0", + "display_key": "_postfinancecheckout_coupon_discount_line_item_discounts", + "display_value": "0" + }, + { + "id": 3282, + "key": "selected_item_post_id", + "value": [ + { + "33576": { + "field_id": 33576, + "value": "34087", + "field_type": "drop_down" + }, + "35086": { + "field_id": 35086, + "value": "35087", + "field_type": "drop_down" + }, + "34779": { + "field_id": 34779, + "value": [ + "34781" + ], + "field_type": "check_boxes" + } + } + ], + "display_key": "selected_item_post_id", + "display_value": [ + { + "33576": { + "field_id": 33576, + "value": "34074", + "field_type": "drop_down" + }, + "35086": { + "field_id": 35086, + "value": "35087", + "field_type": "drop_down" + }, + "34779": { + "field_id": 34779, + "value": [ + "34781" + ], + "field_type": "check_boxes" + } + } + ] + }, + { + "id": 3283, + "key": "_dokan_commission_rate", + "value": "0", + "display_key": "_dokan_commission_rate", + "display_value": "0" + }, + { + "id": 3284, + "key": "_dokan_commission_type", + "value": "flat", + "display_key": "_dokan_commission_type", + "display_value": "flat" + } + ], + "sku": "", + "price": 123, + "image": { + "id": "34018", + "src": "https:\/\/locali-ge.ch\/wp-content\/uploads\/2024\/09\/Capture-decran-2024-09-05-a-16.29.08.png" + }, + "parent_name": null + } + ], + "tax_lines": [], + "shipping_lines": [], + "fee_lines": [], + "coupon_lines": [], + "refunds": [], + "payment_url": "https:\/\/locali-ge.ch\/commander\/order-pay\/35255\/?pay_for_order=true&key=wc_order_SKGPs39HZHZht", + "is_editable": false, + "needs_payment": true, + "needs_processing": true, + "date_created_gmt": "2024-09-11T10:41:55", + "date_modified_gmt": "2024-09-11T10:41:57", + "date_completed_gmt": null, + "date_paid_gmt": null, + "currency_symbol": "CHF", + "stores": [ + { + "id": 31, + "name": "Jardins de Cocagne", + "shop_name": "Les Jardins de Cocagne", + "url": "https:\/\/locali-ge.ch\/nos-magasins\/cocagne\/", + "address": { + "street_1": "66 ch. des Plant\u00e9es", + "street_2": "", + "city": "S\u00e9zegnin-Athenaz", + "zip": "1285", + "country": "CH", + "state": "" + } + } + ], + "store": { + "id": 31, + "name": "Jardins de Cocagne", + "shop_name": "Les Jardins de Cocagne", + "url": "https:\/\/locali-ge.ch\/nos-magasins\/cocagne\/", + "address": { + "street_1": "66 ch. des Plant\u00e9es", + "street_2": "", + "city": "S\u00e9zegnin-Athenaz", + "zip": "1285", + "country": "CH", + "state": "" + } + }, + "_links": { + "self": [ + { + "href": "https:\/\/locali-ge.ch\/wp-json\/wc\/v3\/orders\/35255" + } + ], + "collection": [ + { + "href": "https:\/\/locali-ge.ch\/wp-json\/wc\/v3\/orders" + } + ], + "customer": [ + { + "href": "https:\/\/locali-ge.ch\/wp-json\/wc\/v3\/customers\/2" + } + ] + } +}