waffo-pancake 0.1.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c5283cad4a209984c83a1416a5b8a56ba039935e1d7c4ed3879349f5eeb6e5f7
4
- data.tar.gz: 76a97790c5458a0ce01f9edc5c88f54bada6b04d29abd2904f768b35b06d4213
3
+ metadata.gz: b498f6ef1ac1fbdec66122b1a9d4758ebd6ea917840563c8f296c9e020c03bdd
4
+ data.tar.gz: c34ede893f8f1a84c2d6f7e71968dddfed09ce1a20e43140156805cf2bf93e39
5
5
  SHA512:
6
- metadata.gz: 9f96eae8643cd2f8bc76c8ad36c4b48f991b0df654a1c696bf5cb53c5bbe0a48f9fa291809e3a3370aed504d3b7aeeda34b959ad91312cc62f1d21c0c0c95569
7
- data.tar.gz: 21cc4f957a948cce53f880578094f8fece93ce4cc58f73500d1d82b0f8f3ef62d41f3ac012d4df92a42e017b99e53c85a269475445277b397938376714bebc4c
6
+ metadata.gz: bee472c21659562c0a68275d615eb5af3f21d95ec1937f2aed7fab7a5570b1f968e6b27f3ff4bbd7fa82598eef3e30407b35afbf8503759cca44490aa9e3bb85
7
+ data.tar.gz: f96b92d6942e7a94e298153a02c1f9a5b480afb1c399ba420da5044964bbe7f946ca6d02f09c54086d256f2881485ff250c166536223d7493122fc01b8577926
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1
4
+
5
+ - `Client#subscription_order` also returns the order's current product as `productId`, read
6
+ from `subscriptionProduct { id }`, so a plan change to another product can be detected.
7
+
8
+ ## 0.2.0
9
+
10
+ - `Waffo::Pancake.configure` / `.configuration`: process-wide settings that start from the
11
+ `WAFFO_*` environment variables, a shared `Waffo::Pancake.client`, and
12
+ `Waffo::Pancake.verify_webhook` using the configured environment, key and tolerances.
13
+ `Client.new` arguments now default to the configuration (still the environment variables when
14
+ nothing is configured).
15
+ - Rails integration, loaded only inside a Rails app:
16
+ - A Railtie that reads `credentials.waffo` and `config.waffo_pancake`, logs to `Rails.logger`
17
+ and filters `private_key` from logs.
18
+ - `Waffo::Pancake::WebhookController`, a controller concern that verifies the signature
19
+ before the action, keeps the payload out of params and the request log, and acknowledges
20
+ deliveries for another environment or store without running the action.
21
+ - `bin/rails generate waffo_pancake:install`: initializer, route, webhook controller, and
22
+ (unless `--skip-events-table`) a `waffo_webhook_events` table, model and job.
23
+ - Instrumentation: `request.waffo_pancake` for each API request and
24
+ `verify_webhook.waffo_pancake` for each webhook check, through ActiveSupport::Notifications
25
+ when it is loaded or any `instrumenter` you configure.
26
+ - `Waffo::Pancake::TestHelpers`: signed webhook headers for a throwaway key, event builders, and
27
+ `FakeClient` to stand in for the API.
28
+
3
29
  ## 0.1.0
4
30
 
5
31
  - `Waffo::Pancake::Client`: signed merchant API requests (RSA-SHA256, `X-Merchant-Id` /
data/README.md CHANGED
@@ -14,6 +14,22 @@ gem "waffo-pancake"
14
14
 
15
15
  ## Configure
16
16
 
17
+ Settings come from `Waffo::Pancake.configure`, which starts from the `WAFFO_*` environment
18
+ variables (`WAFFO_MERCHANT_ID`, `WAFFO_PRIVATE_KEY`, `WAFFO_STORE_ID`, `WAFFO_ENVIRONMENT`):
19
+
20
+ ```ruby
21
+ Waffo::Pancake.configure do |config|
22
+ config.merchant_id = "MER_..."
23
+ config.private_key = File.read("waffo.pem")
24
+ config.store_id = "STO_..."
25
+ config.environment = "prod" # which webhook key to trust; nil tries prod, then test
26
+ end
27
+
28
+ Waffo::Pancake.client.cancel_subscription("ORD_...") # a client built from the configuration
29
+ ```
30
+
31
+ Or build a client yourself:
32
+
17
33
  Create an API key in the Dashboard (**API & Development**). A key is bound to test or prod
18
34
  when it is created, so the key decides the environment.
19
35
 
@@ -115,6 +131,94 @@ both), or pass `public_key:`. Timestamps may be up to 45 minutes old and 1 minut
115
131
 
116
132
  `Waffo::Pancake::Webhook::EVENT_TYPES` lists the event types.
117
133
 
134
+ ## Rails
135
+
136
+ Inside a Rails app the gem configures itself. Settings are read in this order, the last one
137
+ winning: `WAFFO_*` environment variables, `credentials.waffo`, `config.waffo_pancake`, then
138
+ `Waffo::Pancake.configure` in an initializer. The logger is `Rails.logger`, and `private_key`
139
+ is added to `filter_parameters`.
140
+
141
+ ```yaml
142
+ # bin/rails credentials:edit
143
+ waffo:
144
+ merchant_id: MER_...
145
+ private_key: "-----BEGIN PRIVATE KEY-----\n..."
146
+ store_id: STO_...
147
+ environment: prod
148
+ ```
149
+
150
+ ```sh
151
+ bin/rails generate waffo_pancake:install # add --skip-events-table to leave out the table
152
+ bin/rails db:migrate
153
+ ```
154
+
155
+ The generator adds `config/initializers/waffo_pancake.rb`, a `POST /webhooks/waffo` route, and
156
+ `WaffoWebhooksController`. Unless `--skip-events-table` is given it also adds a
157
+ `waffo_webhook_events` table (one row per delivery id, so a retry is not handled twice), its
158
+ model and a `WaffoWebhookJob` to fill in.
159
+
160
+ The controller uses the `Waffo::Pancake::WebhookController` concern, which you can also include
161
+ in your own controller:
162
+
163
+ ```ruby
164
+ class WaffoWebhooksController < ActionController::Base
165
+ include Waffo::Pancake::WebhookController
166
+
167
+ def create
168
+ HandleWaffoEventJob.perform_later(waffo_event) # verified, parsed delivery
169
+ head :ok
170
+ end
171
+ end
172
+ ```
173
+
174
+ Before the action the concern:
175
+
176
+ - verifies `X-Waffo-Signature` with the configured key, and answers 401 if it fails;
177
+ - answers 413 for a body over 256 KB;
178
+ - answers 200 without running the action when the delivery is for another `environment` or `store_id` (when those are configured), since an error status only makes Waffo retry it;
179
+ - keeps the payload out of `params` and the request log.
180
+
181
+ ### Instrumentation
182
+
183
+ With ActiveSupport loaded (or any object answering `instrument(name, payload) { }` set as
184
+ `config.instrumenter`), the gem reports:
185
+
186
+ | Event | Payload |
187
+ |---|---|
188
+ | `request.waffo_pancake` | `method`, `path`, `status` (nil when there was no answer), `exception` when raised |
189
+ | `verify_webhook.waffo_pancake` | `environment`, `event_id`, `event_type`, `mode`, `exception` when refused |
190
+
191
+ ```ruby
192
+ ActiveSupport::Notifications.subscribe("request.waffo_pancake") do |event|
193
+ Rails.logger.info("Waffo #{event.payload[:path]} #{event.payload[:status]} #{event.duration.round}ms")
194
+ end
195
+ ```
196
+
197
+ ### Testing
198
+
199
+ ```ruby
200
+ # spec/rails_helper.rb (or test/test_helper.rb with ActiveSupport::TestCase)
201
+ require "waffo/pancake/test_helpers"
202
+ RSpec.configure { |config| config.include Waffo::Pancake::TestHelpers }
203
+
204
+ it "accepts a signed delivery" do
205
+ body = waffo_webhook_event("subscription.activated", data: { "orderId" => "ORD_1" }).to_json
206
+ with_waffo_webhook_key { post "/webhooks/waffo", params: body, headers: waffo_webhook_headers(body) }
207
+ expect(response).to have_http_status(:ok)
208
+ end
209
+
210
+ it "cancels at Waffo" do
211
+ with_fake_waffo_client(Waffo::Pancake::TestHelpers::FakeClient.new("ORD_1" => { "status" => "active" })) do |waffo|
212
+ post "/billing/cancel"
213
+ expect(waffo.calls).to include([:cancel_subscription, "ORD_1"])
214
+ end
215
+ end
216
+ ```
217
+
218
+ `FakeClient` answers `subscription_order` from the orders it was given, moves an order to
219
+ `canceling` / `active` on cancel / reactivate, records every call in `calls`, and raises
220
+ `fail_with` when set.
221
+
118
222
  ## Errors
119
223
 
120
224
  | Class | When |
@@ -142,10 +246,11 @@ Test cards: `4576 7500 0000 0110` succeeds and `4576 7500 0000 0220` is declined
142
246
 
143
247
  ```sh
144
248
  bundle install
145
- bundle exec rake test
249
+ bundle exec rake test # test:core (no Rails loaded) and test:rails
146
250
  ```
147
251
 
148
- Releases are published to RubyGems by the `Release` workflow when a `v*` tag is pushed. It uses
252
+ Releases are published to RubyGems by the `Release` workflow when a `v*` tag is pushed (see
253
+ [RELEASING.md](RELEASING.md)). It uses
149
254
  [trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no API key is stored.
150
255
 
151
256
  ## License
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+
6
+ module WaffoPancake
7
+ module Generators
8
+ # bin/rails generate waffo_pancake:install [--skip-events-table]
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ desc "Adds a Waffo Pancake initializer and a verified webhook endpoint, with a table that " \
15
+ "records each delivery once and a job that handles it."
16
+
17
+ class_option :events_table, type: :boolean, default: true,
18
+ desc: "Record deliveries in waffo_webhook_events and handle them in a job"
19
+
20
+ def self.next_migration_number(dirname)
21
+ require "rails/generators/active_record"
22
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
23
+ end
24
+
25
+ def create_initializer
26
+ template "initializer.rb", "config/initializers/waffo_pancake.rb"
27
+ end
28
+
29
+ def create_controller
30
+ template "webhooks_controller.rb", "app/controllers/waffo_webhooks_controller.rb"
31
+ end
32
+
33
+ def add_route
34
+ route 'post "webhooks/waffo", to: "waffo_webhooks#create", as: :waffo_webhook'
35
+ end
36
+
37
+ def create_events_table
38
+ return unless events_table?
39
+
40
+ migration_template "create_waffo_webhook_events.rb", "db/migrate/create_waffo_webhook_events.rb"
41
+ template "webhook_event.rb", "app/models/waffo_webhook_event.rb"
42
+ template "webhook_job.rb", "app/jobs/waffo_webhook_job.rb"
43
+ end
44
+
45
+ def show_next_steps
46
+ say <<~TEXT
47
+
48
+ Waffo Pancake is installed. Next:
49
+ 1. Add credentials (bin/rails credentials:edit) or WAFFO_* environment variables:
50
+ waffo:
51
+ merchant_id: MER_...
52
+ private_key: "-----BEGIN PRIVATE KEY-----\\n..."
53
+ store_id: STO_...
54
+ environment: test
55
+ #{events_table? ? "2. bin/rails db:migrate" : "2. Deduplicate deliveries on waffo_event[\"id\"] in WaffoWebhooksController"}
56
+ 3. In the Waffo Dashboard, add a webhook pointing at https://<your host>/webhooks/waffo
57
+ and subscribe to the events you handle.
58
+ TEXT
59
+ end
60
+
61
+ private
62
+ def events_table? = options[:events_table]
63
+
64
+ def migration_version
65
+ "[#{::ActiveRecord::Migration.current_version}]" if defined?(::ActiveRecord::Migration)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,16 @@
1
+ class CreateWaffoWebhookEvents < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :waffo_webhook_events do |t|
4
+ t.string :delivery_id, null: false
5
+ t.string :event_type, null: false
6
+ t.string :mode, null: false
7
+ t.string :order_id
8
+ t.json :payload, null: false
9
+ t.datetime :processed_at
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :waffo_webhook_events, :delivery_id, unique: true
14
+ add_index :waffo_webhook_events, :order_id
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # Waffo Pancake (the waffo-pancake gem). Settings are read in this order, the last one winning:
2
+ # WAFFO_* environment variables, credentials.waffo, config.waffo_pancake, then this block.
3
+ #
4
+ # # bin/rails credentials:edit
5
+ # waffo:
6
+ # merchant_id: MER_...
7
+ # private_key: "-----BEGIN PRIVATE KEY-----\n..." # PEM, or bare base64
8
+ # store_id: STO_...
9
+ # environment: test # or prod
10
+ #
11
+ # Waffo::Pancake.client is a client built from these settings; webhook deliveries are verified
12
+ # by Waffo::Pancake::WebhookController. Requests are reported as `request.waffo_pancake` and
13
+ # webhook checks as `verify_webhook.waffo_pancake` ActiveSupport::Notifications events.
14
+ Waffo::Pancake.configure do |config|
15
+ # config.environment = Rails.env.production? ? "prod" : "test"
16
+ # config.timeout = 15
17
+ end
@@ -0,0 +1,17 @@
1
+ # One verified Waffo Pancake delivery. The unique delivery_id makes a redelivery a no-op.
2
+ class WaffoWebhookEvent < ApplicationRecord
3
+ # Returns [event, created?]; two deliveries racing on the unique index share the one row.
4
+ def self.record(event)
5
+ attributes = {
6
+ event_type: event["eventType"].to_s,
7
+ mode: event["mode"].to_s,
8
+ order_id: event.dig("data", "orderId"),
9
+ payload: event
10
+ }
11
+ [ create!(delivery_id: event["id"].to_s, **attributes), true ]
12
+ rescue ActiveRecord::RecordNotUnique
13
+ [ find_by!(delivery_id: event["id"].to_s), false ]
14
+ end
15
+
16
+ def data = payload.fetch("data", {})
17
+ end
@@ -0,0 +1,25 @@
1
+ # Handles one Waffo Pancake delivery (WaffoWebhooksController records it first). Event types:
2
+ # https://docs.waffo.ai — Waffo::Pancake::Webhook::EVENT_TYPES lists them.
3
+ class WaffoWebhookJob < ApplicationJob
4
+ retry_on Waffo::Pancake::Unavailable, wait: :polynomially_longer, attempts: 8
5
+
6
+ def perform(event)
7
+ return if event.processed_at
8
+
9
+ case event.event_type
10
+ when "subscription.activated", "subscription.renewed", "subscription.recovered", "subscription.uncanceled"
11
+ # Grant access until event.data["currentPeriodEnd"]. To read the order as Waffo has it
12
+ # now, regardless of delivery order: Waffo::Pancake.client.subscription_order(event.order_id)
13
+ when "subscription.canceling"
14
+ # Access continues until event.data["currentPeriodEnd"]; no renewal follows.
15
+ when "subscription.canceled"
16
+ # Revoke access.
17
+ when "subscription.past_due"
18
+ # The renewal failed; the payment channel retries once, usually the next day.
19
+ when "refund.succeeded"
20
+ # Compare event.data["refundedAmount"] with event.data["originalChargedAmount"].
21
+ end
22
+
23
+ event.update!(processed_at: Time.current)
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ # Receives Waffo Pancake webhooks. The included concern has already verified the signature,
2
+ # the environment and the store before `create` runs; `waffo_event` is the parsed delivery.
3
+ class WaffoWebhooksController < ActionController::Base
4
+ include Waffo::Pancake::WebhookController
5
+
6
+ def create
7
+ <% if events_table? -%>
8
+ event, created = WaffoWebhookEvent.record(waffo_event)
9
+ WaffoWebhookJob.perform_later(event) if created
10
+ <% else -%>
11
+ # A retry redelivers the same waffo_event["id"]; handle each id once.
12
+ Rails.logger.info("Waffo #{waffo_event["eventType"]} #{waffo_event["id"]}")
13
+ <% end -%>
14
+ head :ok
15
+ end
16
+ end
@@ -30,6 +30,7 @@ module Waffo
30
30
  subscriptionOrder(id: $id) {
31
31
  id storeId status testMode buyerEmail merchantProvidedBuyerIdentity orderMerchantExternalId
32
32
  billingPeriod currentPeriodStart currentPeriodEnd canceledAt
33
+ subscriptionProduct { id }
33
34
  }
34
35
  }
35
36
  GRAPHQL
@@ -44,11 +45,14 @@ module Waffo
44
45
  Base64.strict_encode64(private_key.sign(OpenSSL::Digest::SHA256.new, canonical))
45
46
  end
46
47
 
47
- # `transport` replaces the HTTP call: it takes the URI, the headers and the JSON body
48
- # and answers `[status, body]` (status nil when there was no answer). `logger` gets one
49
- # info line per request; neither the body nor the key is ever logged.
50
- def initialize(merchant_id: ENV["WAFFO_MERCHANT_ID"], private_key: ENV["WAFFO_PRIVATE_KEY"],
51
- base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, logger: nil, transport: nil)
48
+ # Every argument defaults to Waffo::Pancake.configuration (itself defaulting to the
49
+ # WAFFO_* environment variables). `transport` replaces the HTTP call: it takes the URI,
50
+ # the headers and the JSON body and answers `[status, body]` (status nil when there was
51
+ # no answer). `logger` gets one info line per request and `instrumenter` one
52
+ # `request.waffo_pancake` event; neither ever sees the body or the key.
53
+ def initialize(config: Pancake.configuration, merchant_id: config.merchant_id, private_key: config.private_key,
54
+ base_url: config.base_url, timeout: config.timeout, logger: config.logger,
55
+ instrumenter: config.instrumenter, transport: nil)
52
56
  raise ConfigurationError, "Missing merchant_id (WAFFO_MERCHANT_ID)" if blank?(merchant_id)
53
57
  raise ConfigurationError, "Missing private_key (WAFFO_PRIVATE_KEY)" if blank?(private_key)
54
58
 
@@ -57,6 +61,7 @@ module Waffo
57
61
  @base_url = base_url.to_s.chomp("/")
58
62
  @timeout = timeout
59
63
  @logger = logger
64
+ @instrumenter = instrumenter
60
65
  @transport = transport || method(:http_post)
61
66
  end
62
67
 
@@ -105,9 +110,11 @@ module Waffo
105
110
  action("/v1/actions/onetime-order/cancel-order", { order_id: order_id })
106
111
  end
107
112
 
108
- # Nil when Waffo knows no such order.
113
+ # Nil when Waffo knows no such order. `productId` is the order's current product, so a plan
114
+ # change to another product can be told apart.
109
115
  def subscription_order(order_id)
110
- graphql(SUBSCRIPTION_ORDER_QUERY, id: order_id)&.dig("subscriptionOrder")
116
+ order = graphql(SUBSCRIPTION_ORDER_QUERY, id: order_id)&.dig("subscriptionOrder")
117
+ order && order.merge("productId" => order.dig("subscriptionProduct", "id"))
111
118
  end
112
119
 
113
120
  # --- Products ----------------------------------------------------------------------
@@ -174,7 +181,9 @@ module Waffo
174
181
  }
175
182
 
176
183
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
177
- status, body = call_transport(URI("#{@base_url}#{path}"), headers, json)
184
+ status, body = instrument("request.waffo_pancake", method: "POST", path: path) do |payload|
185
+ call_transport(URI("#{@base_url}#{path}"), headers, json).tap { |answer| payload[:status] = answer.first }
186
+ end
178
187
  elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(2)
179
188
  @logger&.info("[Waffo] POST #{path} status=#{status.inspect} in #{elapsed}s")
180
189
 
@@ -186,6 +195,12 @@ module Waffo
186
195
  [status, envelope]
187
196
  end
188
197
 
198
+ def instrument(name, payload, &block)
199
+ return yield(payload) unless @instrumenter
200
+
201
+ @instrumenter.instrument(name, payload, &block)
202
+ end
203
+
189
204
  def call_transport(uri, headers, json)
190
205
  @transport.call(uri, headers, json)
191
206
  rescue *NETWORK_ERRORS => e
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waffo
4
+ module Pancake
5
+ # Process-wide settings, read by `Waffo::Pancake.client`, `Waffo::Pancake.verify_webhook`
6
+ # and the Rails integration. Every value starts from its environment variable; the Railtie
7
+ # then applies `credentials.waffo`, and `Waffo::Pancake.configure` has the last word.
8
+ class Configuration
9
+ ENVIRONMENTS = %w[test prod].freeze
10
+
11
+ # MER_..., the API key's private key (PEM, PEM with literal \n, or bare base64), STO_...
12
+ attr_accessor :merchant_id, :private_key, :store_id
13
+ attr_accessor :base_url, :timeout, :logger
14
+ # A key used instead of the built-in platform keys; nil uses those.
15
+ attr_accessor :webhook_public_key
16
+ # Seconds a webhook timestamp may lie in the past (0 skips the check) or in the future.
17
+ attr_accessor :webhook_tolerance, :webhook_future_tolerance
18
+ # Anything answering `instrument(name, payload) { |payload| ... }`. Defaults to
19
+ # ActiveSupport::Notifications when it is loaded; nil turns instrumentation off.
20
+ attr_writer :instrumenter
21
+
22
+ # "test" or "prod": which platform key verifies webhooks, and which deliveries the Rails
23
+ # webhook concern accepts. nil tries prod, then test, and accepts both.
24
+ attr_reader :environment
25
+
26
+ def initialize
27
+ @merchant_id = ENV["WAFFO_MERCHANT_ID"]
28
+ @private_key = ENV["WAFFO_PRIVATE_KEY"]
29
+ @store_id = ENV["WAFFO_STORE_ID"]
30
+ begin
31
+ self.environment = ENV["WAFFO_ENVIRONMENT"]
32
+ rescue ConfigurationError => e
33
+ raise ConfigurationError, "WAFFO_ENVIRONMENT: #{e.message}"
34
+ end
35
+ @base_url = ENV["WAFFO_API_BASE_URL"] || Client::DEFAULT_BASE_URL
36
+ @timeout = Client::DEFAULT_TIMEOUT
37
+ @logger = nil
38
+ @webhook_public_key = nil
39
+ @webhook_tolerance = Webhook::DEFAULT_TOLERANCE
40
+ @webhook_future_tolerance = Webhook::DEFAULT_FUTURE_TOLERANCE
41
+ @instrumenter = :default
42
+ end
43
+
44
+ def environment=(value)
45
+ value = value.to_s.strip.downcase
46
+ value = nil if value.empty?
47
+ unless value.nil? || ENVIRONMENTS.include?(value)
48
+ raise ConfigurationError, "environment must be test or prod, got #{value.inspect}"
49
+ end
50
+
51
+ @environment = value
52
+ end
53
+
54
+ def instrumenter
55
+ return @instrumenter unless @instrumenter == :default
56
+
57
+ defined?(::ActiveSupport::Notifications) ? ::ActiveSupport::Notifications : nil
58
+ end
59
+
60
+ # What a client needs; a store id is only checked by the webhook concern.
61
+ def configured?
62
+ !blank?(merchant_id) && !blank?(private_key)
63
+ end
64
+
65
+ private
66
+ def blank?(value) = value.nil? || value.to_s.strip.empty?
67
+ end
68
+
69
+ @mutex = Mutex.new
70
+
71
+ class << self
72
+ def configuration
73
+ @configuration || @mutex.synchronize { @configuration ||= Configuration.new }
74
+ end
75
+
76
+ # Yields the configuration; the shared client is rebuilt on its next use.
77
+ def configure
78
+ yield configuration
79
+ reset_client!
80
+ configuration
81
+ end
82
+
83
+ # A client built from the configuration, shared by the process. Raises
84
+ # ConfigurationError while the merchant id or the key is missing.
85
+ def client
86
+ return @client if @client
87
+
88
+ config = configuration
89
+ @mutex.synchronize { @client ||= Client.new(config: config) }
90
+ end
91
+
92
+ # Replaces the shared client, for example with Waffo::Pancake::TestHelpers::FakeClient.
93
+ attr_writer :client
94
+
95
+ def reset_client!
96
+ @mutex.synchronize { @client = nil }
97
+ end
98
+
99
+ # Back to the environment variables, with no shared client.
100
+ def reset!
101
+ @mutex.synchronize do
102
+ @configuration = nil
103
+ @client = nil
104
+ end
105
+ end
106
+
107
+ # Webhook.verify with the configured environment, key and tolerances, reported as a
108
+ # `verify_webhook.waffo_pancake` event (with `:exception` when it is refused).
109
+ def verify_webhook(payload, signature_header, now: Time.now)
110
+ config = configuration
111
+ verify = lambda do |event_payload|
112
+ event = Webhook.verify(payload, signature_header, environment: config.environment,
113
+ public_key: config.webhook_public_key,
114
+ tolerance: config.webhook_tolerance,
115
+ future_tolerance: config.webhook_future_tolerance, now: now)
116
+ event_payload.merge!(event_id: event["id"], event_type: event["eventType"], mode: event["mode"]) if event.is_a?(Hash)
117
+ event
118
+ end
119
+
120
+ instrumenter = config.instrumenter
121
+ details = { environment: config.environment }
122
+ instrumenter ? instrumenter.instrument("verify_webhook.waffo_pancake", details, &verify) : verify.call(details)
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Waffo
6
+ module Pancake
7
+ # Loaded only inside a Rails app. Settings are applied in this order, the last one winning:
8
+ #
9
+ # 1. WAFFO_* environment variables
10
+ # 2. credentials.waffo (merchant_id, private_key, store_id, environment, webhook_public_key)
11
+ # 3. config.waffo_pancake.* in config/application.rb or an environment file
12
+ # 4. Waffo::Pancake.configure in config/initializers
13
+ #
14
+ # The logger defaults to Rails.logger, and `private_key` is filtered from logs.
15
+ class Railtie < ::Rails::Railtie
16
+ CREDENTIAL_KEYS = %i[merchant_id private_key store_id environment webhook_public_key].freeze
17
+
18
+ config.waffo_pancake = ActiveSupport::OrderedOptions.new
19
+
20
+ initializer "waffo_pancake.filter_parameters" do |app|
21
+ app.config.filter_parameters |= [:private_key]
22
+ end
23
+
24
+ initializer "waffo_pancake.configuration" do |app|
25
+ Waffo::Pancake.configure do |config|
26
+ credentials = app.credentials[:waffo] if app.respond_to?(:credentials)
27
+ CREDENTIAL_KEYS.each do |key|
28
+ value = credentials&.[](key)
29
+ config.public_send(:"#{key}=", value) unless value.nil? || value.to_s.strip.empty?
30
+ end
31
+
32
+ app.config.waffo_pancake.each { |key, value| config.public_send(:"#{key}=", value) }
33
+ config.logger ||= ::Rails.logger
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+ require "waffo/pancake"
6
+
7
+ module Waffo
8
+ module Pancake
9
+ # Helpers for an app's own tests (Minitest or RSpec):
10
+ #
11
+ # # test/test_helper.rb # spec/rails_helper.rb
12
+ # require "waffo/pancake/test_helpers" require "waffo/pancake/test_helpers"
13
+ # class ActiveSupport::TestCase RSpec.configure do |config|
14
+ # include Waffo::Pancake::TestHelpers config.include Waffo::Pancake::TestHelpers
15
+ # end end
16
+ #
17
+ # `with_waffo_webhook_key` makes the configuration trust a throwaway key, and
18
+ # `waffo_webhook_headers` signs a body with it, so a request test can post a delivery the
19
+ # real verification accepts. `with_fake_waffo_client` swaps the shared client for a
20
+ # FakeClient, which records calls instead of making them.
21
+ module TestHelpers
22
+ # Generated once per process: RSA key generation is slow.
23
+ def self.key
24
+ @key ||= OpenSSL::PKey::RSA.generate(2048)
25
+ end
26
+
27
+ # Webhook verification trusts TestHelpers.key (and only it) inside the block.
28
+ def with_waffo_webhook_key(key = TestHelpers.key)
29
+ config = Waffo::Pancake.configuration
30
+ previous = config.webhook_public_key
31
+ config.webhook_public_key = key.public_key
32
+ yield
33
+ ensure
34
+ config.webhook_public_key = previous
35
+ end
36
+
37
+ # `t=<ms>,v1=<signature>` over the body, as Waffo sends it.
38
+ def waffo_webhook_signature(body, at: Time.now, key: TestHelpers.key)
39
+ timestamp = (at.to_f * 1000).to_i
40
+ signature = Base64.strict_encode64(key.sign(OpenSSL::Digest::SHA256.new, "#{timestamp}.#{body}"))
41
+ "t=#{timestamp},v1=#{signature}"
42
+ end
43
+
44
+ # Headers for posting `body` (a String) to a webhook endpoint.
45
+ def waffo_webhook_headers(body, **options)
46
+ { "CONTENT_TYPE" => "application/json", Webhook::SIGNATURE_HEADER => waffo_webhook_signature(body, **options) }
47
+ end
48
+
49
+ # A webhook envelope in Waffo's shape. `mode` and `store_id` default to the configuration.
50
+ def waffo_webhook_event(event_type, data: {}, id: SecureRandom.uuid, mode: nil, store_id: nil)
51
+ config = Waffo::Pancake.configuration
52
+ {
53
+ "id" => id,
54
+ "timestamp" => Time.now.utc.iso8601,
55
+ "eventType" => event_type,
56
+ "eventId" => "evt_#{SecureRandom.hex(8)}",
57
+ "storeId" => store_id || config.store_id || "STO_test",
58
+ "storeName" => "Test store",
59
+ "mode" => mode || config.environment || "test",
60
+ "data" => data
61
+ }
62
+ end
63
+
64
+ # Waffo::Pancake.client is `client` inside the block. Yields the client.
65
+ def with_fake_waffo_client(client = FakeClient.new)
66
+ previous = Waffo::Pancake.instance_variable_get(:@client)
67
+ Waffo::Pancake.client = client
68
+ yield client
69
+ ensure
70
+ Waffo::Pancake.client = previous
71
+ end
72
+
73
+ # Stands in for Waffo::Pancake::Client. Answers subscription_order from `orders`, moves
74
+ # an order's status on cancel / reactivate the way Waffo does, and records every call
75
+ # in `calls` as [method, arguments]. Set `fail_with` to an error to make calls raise it.
76
+ class FakeClient
77
+ attr_reader :orders, :calls
78
+ attr_accessor :fail_with, :checkout_url
79
+
80
+ def initialize(orders = {})
81
+ @orders = orders.transform_keys(&:to_s)
82
+ @calls = []
83
+ @checkout_url = "https://pancake.waffo.ai/store/test/checkout/cs_test"
84
+ end
85
+
86
+ def subscription_order(order_id)
87
+ record(:subscription_order, order_id)
88
+ order = @orders[order_id.to_s]
89
+ order && order.dup
90
+ end
91
+
92
+ def create_checkout_session(**params)
93
+ record(:create_checkout_session, params)
94
+ { "sessionId" => "cs_test", "checkoutUrl" => checkout_url, "expiresAt" => (Time.now + 2700).utc.iso8601 }
95
+ end
96
+
97
+ def create_authenticated_checkout(**params)
98
+ record(:create_authenticated_checkout, params)
99
+ { "sessionId" => "cs_test", "checkoutUrl" => "#{checkout_url}#token=test_token", "token" => "test_token",
100
+ "expiresAt" => (Time.now + 2700).utc.iso8601 }
101
+ end
102
+
103
+ def cancel_subscription(order_id)
104
+ record(:cancel_subscription, order_id)
105
+ change_status(order_id, "canceling")
106
+ end
107
+
108
+ def reactivate_subscription(order_id)
109
+ record(:reactivate_subscription, order_id)
110
+ change_status(order_id, "active")
111
+ end
112
+
113
+ def graphql(query, variables = {})
114
+ record(:graphql, query, variables)
115
+ {}
116
+ end
117
+
118
+ def action(path, params)
119
+ record(:action, path, params)
120
+ {}
121
+ end
122
+
123
+ def called?(name) = calls.any? { |call| call.first == name }
124
+
125
+ private
126
+ def record(name, *arguments)
127
+ calls << [name, *arguments]
128
+ raise fail_with if fail_with
129
+ end
130
+
131
+ def change_status(order_id, status)
132
+ @orders[order_id.to_s]&.merge!("status" => status)
133
+ { "orderId" => order_id, "status" => status }
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Waffo
4
4
  module Pancake
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Waffo
6
+ module Pancake
7
+ # Include in the controller that receives Waffo's webhooks:
8
+ #
9
+ # class WaffoWebhooksController < ActionController::Base
10
+ # include Waffo::Pancake::WebhookController
11
+ #
12
+ # def create
13
+ # HandleWaffoEventJob.perform_later(waffo_event) # already verified
14
+ # head :ok
15
+ # end
16
+ # end
17
+ #
18
+ # Before the action it reads the raw body, verifies X-Waffo-Signature with
19
+ # Waffo::Pancake.verify_webhook and exposes the event as `waffo_event`. A bad signature is
20
+ # answered 401 and an oversized body 413, without reaching the action. A delivery for
21
+ # another environment or store (when `environment` / `store_id` are configured) is
22
+ # acknowledged with 200 and skipped, since an error status only makes Waffo retry it.
23
+ #
24
+ # Deduplicate on `waffo_event["id"]`: a retry redelivers the same id.
25
+ module WebhookController
26
+ extend ActiveSupport::Concern
27
+
28
+ MAX_BODY_BYTES = 256 * 1024
29
+
30
+ included do
31
+ skip_forgery_protection if respond_to?(:skip_forgery_protection)
32
+ wrap_parameters false if respond_to?(:wrap_parameters)
33
+ before_action :verify_waffo_webhook!
34
+ end
35
+
36
+ # The body is trusted only once its signature checks out. Left to Rails it would be
37
+ # parsed into params first and written whole into the request log.
38
+ def process_action(...)
39
+ request.delete_header("action_dispatch.request.parameters")
40
+ request.request_parameters = {}
41
+ super
42
+ end
43
+
44
+ private
45
+ attr_reader :waffo_event
46
+
47
+ def verify_waffo_webhook!
48
+ body = request.body.read(MAX_BODY_BYTES + 1).to_s
49
+ request.body.rewind if request.body.respond_to?(:rewind)
50
+ return head(413) if body.bytesize > MAX_BODY_BYTES
51
+
52
+ @waffo_event = Waffo::Pancake.verify_webhook(body, request.headers[Webhook::SIGNATURE_HEADER])
53
+ return head(:bad_request) unless @waffo_event.is_a?(Hash) && @waffo_event["id"]
54
+ return if waffo_event_for_this_account?
55
+
56
+ waffo_webhook_log(:info, "skipped event=#{@waffo_event["id"]} mode=#{@waffo_event["mode"]} " \
57
+ "store=#{@waffo_event["storeId"]}: not this environment or store")
58
+ head :ok
59
+ rescue Waffo::Pancake::InvalidSignature => e
60
+ waffo_webhook_log(:warn, "refused: #{e.message}")
61
+ head :unauthorized
62
+ end
63
+
64
+ def waffo_event_for_this_account?
65
+ config = Waffo::Pancake.configuration
66
+ return false if config.environment && waffo_event["mode"] != config.environment
67
+
68
+ config.store_id.to_s.strip.empty? || waffo_event["storeId"] == config.store_id
69
+ end
70
+
71
+ def waffo_webhook_log(level, message)
72
+ (Waffo::Pancake.configuration.logger || logger)&.public_send(level, "[Waffo] webhook #{message}")
73
+ end
74
+ end
75
+ end
76
+ end
data/lib/waffo/pancake.rb CHANGED
@@ -5,9 +5,14 @@ require_relative "pancake/errors"
5
5
  require_relative "pancake/keys"
6
6
  require_relative "pancake/client"
7
7
  require_relative "pancake/webhook"
8
+ require_relative "pancake/configuration"
9
+ require_relative "pancake/railtie" if defined?(Rails::Railtie)
8
10
 
9
11
  module Waffo
10
12
  # Ruby client for the Waffo Pancake merchant API (https://docs.waffo.ai).
11
13
  module Pancake
14
+ # Need ActiveSupport / Action Pack; loaded on first use.
15
+ autoload :WebhookController, "waffo/pancake/webhook_controller"
16
+ autoload :TestHelpers, "waffo/pancake/test_helpers"
12
17
  end
13
18
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: waffo-pancake
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Good First Issue
@@ -34,12 +34,22 @@ files:
34
34
  - CHANGELOG.md
35
35
  - LICENSE
36
36
  - README.md
37
+ - lib/generators/waffo_pancake/install/install_generator.rb
38
+ - lib/generators/waffo_pancake/install/templates/create_waffo_webhook_events.rb.tt
39
+ - lib/generators/waffo_pancake/install/templates/initializer.rb.tt
40
+ - lib/generators/waffo_pancake/install/templates/webhook_event.rb.tt
41
+ - lib/generators/waffo_pancake/install/templates/webhook_job.rb.tt
42
+ - lib/generators/waffo_pancake/install/templates/webhooks_controller.rb.tt
37
43
  - lib/waffo/pancake.rb
38
44
  - lib/waffo/pancake/client.rb
45
+ - lib/waffo/pancake/configuration.rb
39
46
  - lib/waffo/pancake/errors.rb
40
47
  - lib/waffo/pancake/keys.rb
48
+ - lib/waffo/pancake/railtie.rb
49
+ - lib/waffo/pancake/test_helpers.rb
41
50
  - lib/waffo/pancake/version.rb
42
51
  - lib/waffo/pancake/webhook.rb
52
+ - lib/waffo/pancake/webhook_controller.rb
43
53
  homepage: https://github.com/goodfirstissueorg/waffo-pancake-ruby
44
54
  licenses:
45
55
  - MIT
@@ -63,7 +73,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
63
73
  - !ruby/object:Gem::Version
64
74
  version: '0'
65
75
  requirements: []
66
- rubygems_version: 4.0.3
76
+ rubygems_version: 3.6.9
67
77
  specification_version: 4
68
78
  summary: Ruby client for the Waffo Pancake merchant API
69
79
  test_files: []