waffo-pancake 0.2.1 → 0.4.0

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.
@@ -2,9 +2,17 @@
2
2
 
3
3
  require "base64"
4
4
  require "json"
5
- require "net/http"
6
5
  require "openssl"
7
- require "uri"
6
+ require "time"
7
+
8
+ require_relative "connection"
9
+ require_relative "requests"
10
+ require_relative "api/customer_actions"
11
+ require_relative "api/store_actions"
12
+ require_relative "api/product_actions"
13
+ require_relative "api/promotion_actions"
14
+ require_relative "api/queries"
15
+ require_relative "customer_session"
8
16
 
9
17
  module Waffo
10
18
  module Pancake
@@ -16,14 +24,19 @@ module Waffo
16
24
  # client.cancel_subscription("ORD_...") # => {"orderId" => "ORD_...", "status" => "canceling"}
17
25
  #
18
26
  # Parameters may be given in snake_case; top-level keys are sent in camelCase. Nested
19
- # hashes (metadata, prices, billing details) are sent exactly as given.
27
+ # hashes (metadata, prices, billing details) are sent exactly as given. Every action takes
28
+ # `idempotency_key:`. Routes that do not take an API key are reached through #customer.
20
29
  class Client
30
+ include Requests
31
+ include API::CustomerActions
32
+ include API::StoreActions
33
+ include API::ProductActions
34
+ include API::PromotionActions
35
+ include API::Queries
36
+
21
37
  DEFAULT_BASE_URL = "https://api.waffo.ai"
22
38
  DEFAULT_TIMEOUT = 15
23
-
24
- NETWORK_ERRORS = [
25
- Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
26
- ].freeze
39
+ NETWORK_ERRORS = Connection::NETWORK_ERRORS
27
40
 
28
41
  SUBSCRIPTION_ORDER_QUERY = <<~GRAPHQL
29
42
  query ($id: String!) {
@@ -35,7 +48,11 @@ module Waffo
35
48
  }
36
49
  GRAPHQL
37
50
 
38
- attr_reader :merchant_id, :base_url
51
+ WEBHOOK_DELIVERY_FIELDS = "id storeId eventType eventId payload webhookUrl status httpStatus responseBody " \
52
+ "attemptCount lastAttemptedAt createdAt"
53
+ WEBHOOK_DELIVERY_STATUSES = %w[pending success failed].freeze
54
+
55
+ attr_reader :merchant_id, :base_url, :store_id, :environment
39
56
 
40
57
  # The canonical request the gateway checks `X-Signature` against: method, path,
41
58
  # timestamp (seconds) and the base64 SHA-256 of the body, one per line.
@@ -47,42 +64,50 @@ module Waffo
47
64
 
48
65
  # Every argument defaults to Waffo::Pancake.configuration (itself defaulting to the
49
66
  # 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.
67
+ # the headers and the JSON body and answers `[status, body]` or `[status, body, headers]`
68
+ # (status nil when there was no answer). `logger` gets one info line per request and
69
+ # `instrumenter` one `request.waffo_pancake` event; neither ever sees the body or the key.
70
+ #
71
+ # The key's environment cannot be read from the key, so `environment` only tells #customer
72
+ # and create_authenticated_plan_change which one to use.
53
73
  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)
74
+ store_id: config.store_id, environment: config.environment, base_url: config.base_url,
75
+ timeout: config.timeout, logger: config.logger, instrumenter: config.instrumenter, transport: nil)
56
76
  raise ConfigurationError, "Missing merchant_id (WAFFO_MERCHANT_ID)" if blank?(merchant_id)
57
77
  raise ConfigurationError, "Missing private_key (WAFFO_PRIVATE_KEY)" if blank?(private_key)
58
78
 
59
79
  @merchant_id = merchant_id
80
+ @store_id = blank?(store_id) ? nil : store_id
81
+ @environment = blank?(environment) ? nil : Requests.environment!(environment)
60
82
  @private_key = Keys.private_key(private_key)
61
83
  @base_url = base_url.to_s.chomp("/")
62
- @timeout = timeout
63
- @logger = logger
64
- @instrumenter = instrumenter
65
- @transport = transport || method(:http_post)
84
+ @connection = Connection.new(base_url: @base_url, timeout: timeout, logger: logger, instrumenter: instrumenter,
85
+ transport: transport)
86
+ end
87
+
88
+ def inspect = "#<#{self.class} merchant_id=#{@merchant_id.inspect} environment=#{@environment.inspect}>"
89
+
90
+ # The same connection, authenticated as a customer with a token from issue_session_token.
91
+ def customer(token, environment: @environment)
92
+ CustomerSession.new(token: token, environment: environment, connection: @connection)
66
93
  end
67
94
 
68
95
  # --- Checkout ----------------------------------------------------------------------
69
96
 
70
- def issue_session_token(buyer_identity:, store_id: nil, product_id: nil)
97
+ # `purpose: "portal"` signs the customer into the store's customer portal instead; see
98
+ # create_customer_portal_link.
99
+ def issue_session_token(buyer_identity:, store_id: nil, product_id: nil, purpose: nil, idempotency_key: nil)
71
100
  raise ArgumentError, "store_id or product_id is required" if store_id.nil? && product_id.nil?
72
101
 
73
- action("/v1/actions/auth/issue-session-token",
74
- { buyer_identity: buyer_identity, store_id: store_id, product_id: product_id })
75
- end
76
-
77
- # Anonymous checkout: the buyer fills everything in on the page.
78
- # Returns {"sessionId", "checkoutUrl", "expiresAt"}.
79
- def create_checkout_session(product_id:, currency:, **params)
80
- action("/v1/actions/checkout/create-session", { product_id: product_id, currency: currency, **params })
102
+ action("/v1/actions/auth/issue-session-token", buyer_identity: buyer_identity, store_id: store_id,
103
+ product_id: product_id, purpose: purpose,
104
+ idempotency_key: idempotency_key)
81
105
  end
82
106
 
83
107
  # Authenticated checkout: `buyer_identity` (your stable id for the customer) goes into a
84
108
  # session token, which Waffo uses for trial eligibility and the customer portal. The
85
- # token is appended to the checkout URL as a fragment (`#token=...`).
109
+ # token is appended to the checkout URL as a fragment (`#token=...`). `idempotency_key`
110
+ # applies to the session only: a token is short-lived and cheap to issue again.
86
111
  def create_authenticated_checkout(product_id:, currency:, buyer_identity:, **params)
87
112
  token = issue_session_token(buyer_identity: buyer_identity, product_id: product_id)
88
113
  session = create_checkout_session(product_id: product_id, currency: currency, **params)
@@ -93,23 +118,50 @@ module Waffo
93
118
  )
94
119
  end
95
120
 
96
- # --- Orders ------------------------------------------------------------------------
97
-
98
- # pending → canceled at once; active, trialing and past_due → canceling, ending with the
99
- # paid period. The terminal `canceled` arrives later as a webhook.
100
- def cancel_subscription(order_id)
101
- action("/v1/actions/subscription-order/cancel-order", { order_id: order_id })
121
+ # A plan change the customer confirms on Waffo's page. The URL is a bearer credential, like
122
+ # the one from create_customer_portal_link. Needs a known environment, because a test
123
+ # session needs `?test=true` before the fragment, and prod must not have it.
124
+ def create_authenticated_plan_change(origin_order_id:, product_id:, currency:, buyer_identity:, **params)
125
+ environment = Requests.environment!(@environment)
126
+ token = issue_session_token(buyer_identity: buyer_identity, product_id: product_id)
127
+ session = create_plan_change_session(origin_order_id: origin_order_id, product_id: product_id,
128
+ currency: currency, **params)
129
+ url = session.fetch("checkoutUrl")
130
+ url = "#{url}#{url.include?("?") ? "&" : "?"}test=true" if environment == "test"
131
+ session.merge(
132
+ "checkoutUrl" => "#{url}#token=#{token.fetch("token")}",
133
+ "token" => token["token"],
134
+ "tokenExpiresAt" => token["expiresAt"]
135
+ )
102
136
  end
103
137
 
104
- # Undoes a cancellation while the order is still `canceling`.
105
- def reactivate_subscription(order_id)
106
- action("/v1/actions/subscription-order/reactivate-order", { order_id: order_id })
107
- end
138
+ # --- Customer portal ----------------------------------------------------------------
139
+
140
+ # Signs a customer who is signed in on your site into the store's customer portal, with no
141
+ # email code. Returns {"portalUrl" => "https://...#token=...", "expiresAt" => ISO 8601}.
142
+ #
143
+ # `buyer_identity` is the one given at authenticated checkout, taken from your own session,
144
+ # never from the request; `store_id` defaults to the configured store. The portal opens on
145
+ # the API key's environment. The URL is a bearer credential: make one per click, answer
146
+ # it with a 302 (Cache-Control: no-store, Referrer-Policy: no-referrer), and never log,
147
+ # cache or send it. Waffo::Pancake::CustomerPortalController does that in Rails.
148
+ def create_customer_portal_link(buyer_identity:, store_id: @store_id)
149
+ raise ArgumentError, "store_id is required (WAFFO_STORE_ID)" if blank?(store_id)
150
+ raise ArgumentError, "buyer_identity is required" if blank?(buyer_identity)
151
+
152
+ path = "/v1/actions/auth/issue-session-token"
153
+ data = issue_session_token(buyer_identity: buyer_identity.to_s, store_id: store_id, purpose: "portal")
154
+ %w[portalUrl token].each do |key|
155
+ next unless blank?(data[key])
156
+
157
+ raise Rejected.new("Waffo #{path}: response has no #{key} for purpose portal", status: 200)
158
+ end
108
159
 
109
- def cancel_onetime_order(order_id)
110
- action("/v1/actions/onetime-order/cancel-order", { order_id: order_id })
160
+ { "portalUrl" => "#{data["portalUrl"]}#token=#{data["token"]}", "expiresAt" => data["expiresAt"] }
111
161
  end
112
162
 
163
+ # --- Orders ------------------------------------------------------------------------
164
+
113
165
  # Nil when Waffo knows no such order. `productId` is the order's current product, so a plan
114
166
  # change to another product can be told apart.
115
167
  def subscription_order(order_id)
@@ -117,130 +169,78 @@ module Waffo
117
169
  order && order.merge("productId" => order.dig("subscriptionProduct", "id"))
118
170
  end
119
171
 
120
- # --- Products ----------------------------------------------------------------------
172
+ # --- Webhook deliveries ------------------------------------------------------------
121
173
 
122
- # prices: { "USD" => { amount: "9.99", taxCategory: "saas" } }
123
- def create_subscription_product(store_id:, name:, billing_period:, prices:, **params)
124
- action("/v1/actions/subscription-product/create-product",
125
- { store_id: store_id, name: name, billing_period: billing_period, prices: prices, **params })
174
+ # Delivery records, newest first, for the API key's environment: id, eventType, eventId,
175
+ # payload, webhookUrl, status (pending / success / failed), httpStatus, responseBody,
176
+ # attemptCount, lastAttemptedAt and createdAt.
177
+ #
178
+ # Waffo has no redelivery: a delivery that failed every retry stays failed. To process it
179
+ # again, hand Waffo::Pancake::Webhook::Event.wrap(delivery["payload"]) to your handler.
180
+ #
181
+ # `filter` takes Waffo's GraphQL filter fields, e.g. { eventType: { eq: "refund.succeeded" } };
182
+ # `status` and `event_type` are shortcuts for an `eq` on those fields.
183
+ def webhook_deliveries(store_id: @store_id, status: nil, event_type: nil, filter: nil, limit: 20, offset: 0)
184
+ filter = delivery_filter(status, event_type, filter)
185
+ query = "query ($storeId: String!, $limit: Int, $offset: Int) { webhookDeliveries(storeId: $storeId, " \
186
+ "limit: $limit, offset: $offset#{filter}) { #{WEBHOOK_DELIVERY_FIELDS} } }"
187
+ graphql(query, storeId: require_store_id(store_id), limit: Integer(limit), offset: Integer(offset))
188
+ &.fetch("webhookDeliveries", nil) || []
126
189
  end
127
190
 
128
- def update_subscription_product(id:, **params)
129
- action("/v1/actions/subscription-product/update-product", { id: id, **params })
191
+ def webhook_deliveries_count(store_id: @store_id, status: nil, event_type: nil, filter: nil)
192
+ filter = delivery_filter(status, event_type, filter)
193
+ query = "query ($storeId: String!) { webhookDeliveriesCount(storeId: $storeId#{filter}) }"
194
+ graphql(query, storeId: require_store_id(store_id))&.fetch("webhookDeliveriesCount", nil)
130
195
  end
131
196
 
132
- # Copies the test version to production (first publish only).
133
- def publish_subscription_product(id:)
134
- action("/v1/actions/subscription-product/publish-product", { id: id })
135
- end
197
+ # A Ruby value as a GraphQL input literal: hashes as input objects, symbols and times as strings.
198
+ def self.graphql_literal(value)
199
+ case value
200
+ when Hash
201
+ fields = value.map do |key, field|
202
+ raise ArgumentError, "invalid GraphQL field name #{key.inspect}" unless key.to_s.match?(/\A[_A-Za-z]\w*\z/)
136
203
 
137
- def create_onetime_product(store_id:, name:, prices:, **params)
138
- action("/v1/actions/onetime-product/create-product", { store_id: store_id, name: name, prices: prices, **params })
139
- end
140
-
141
- # --- Webhook endpoints -------------------------------------------------------------
142
-
143
- def add_webhook(store_id:, url:, events:, test_mode:, channel: "http", **params)
144
- action("/v1/actions/store/add-webhook",
145
- { store_id: store_id, channel: channel, url: url, events: events, test_mode: test_mode, **params })
146
- end
147
-
148
- def remove_webhook(id:)
149
- action("/v1/actions/store/remove-webhook", { id: id })
150
- end
151
-
152
- # --- Low level ---------------------------------------------------------------------
153
-
154
- # Returns `data`. Raises when the response carries `errors` and no data.
155
- def graphql(query, variables = {})
156
- status, envelope = request("/v1/graphql", { "query" => query, "variables" => variables })
157
- raise_for(status, envelope, "/v1/graphql") if present?(envelope["errors"]) && envelope["data"].nil?
158
-
159
- envelope["data"]
160
- end
161
-
162
- # Any action endpoint. Returns the envelope's `data`; raises Rejected or Unavailable on
163
- # `errors` or a non-2xx status.
164
- def action(path, params)
165
- status, envelope = request(path, camelize(params))
166
- raise_for(status, envelope, path) if present?(envelope["errors"]) || !(200..299).cover?(status)
167
-
168
- envelope["data"] || {}
204
+ "#{key}: #{graphql_literal(field)}"
205
+ end
206
+ "{ #{fields.join(", ")} }"
207
+ when Array then "[#{value.map { |item| graphql_literal(item) }.join(", ")}]"
208
+ when Symbol then graphql_literal(value.to_s)
209
+ when Time then JSON.generate(value.utc.iso8601(3))
210
+ when String, Integer, Float, true, false, nil then JSON.generate(value)
211
+ else raise ArgumentError, "cannot send #{value.class} in a GraphQL filter"
212
+ end
169
213
  end
170
214
 
171
215
  private
172
- def request(path, params)
173
- json = JSON.generate(params)
216
+ attr_reader :connection
217
+
218
+ def auth_headers(path, json)
174
219
  timestamp = Time.now.to_i.to_s
175
- headers = {
176
- "Content-Type" => "application/json",
220
+ {
177
221
  "X-Merchant-Id" => @merchant_id,
178
222
  "X-Timestamp" => timestamp,
179
223
  "X-Signature" => self.class.sign(method: "POST", path: path, timestamp: timestamp, body: json,
180
224
  private_key: @private_key)
181
225
  }
182
-
183
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
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
187
- elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(2)
188
- @logger&.info("[Waffo] POST #{path} status=#{status.inspect} in #{elapsed}s")
189
-
190
- raise Unavailable.new("Waffo #{path} unreachable: #{body}", status: nil) if status.nil?
191
-
192
- envelope = parse(body)
193
- raise_for(status, {}, path, "non-JSON response") unless envelope.is_a?(Hash)
194
-
195
- [status, envelope]
196
- end
197
-
198
- def instrument(name, payload, &block)
199
- return yield(payload) unless @instrumenter
200
-
201
- @instrumenter.instrument(name, payload, &block)
202
- end
203
-
204
- def call_transport(uri, headers, json)
205
- @transport.call(uri, headers, json)
206
- rescue *NETWORK_ERRORS => e
207
- [nil, "#{e.class}: #{e.message}"]
208
226
  end
209
227
 
210
- def http_post(uri, headers, json)
211
- request = Net::HTTP::Post.new(uri, headers)
212
- request.body = json
213
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
214
- read_timeout: @timeout, write_timeout: @timeout) do |http|
215
- http.request(request)
228
+ def delivery_filter(status, event_type, filter)
229
+ if status && !WEBHOOK_DELIVERY_STATUSES.include?(status.to_s)
230
+ raise ArgumentError, "status must be one of #{WEBHOOK_DELIVERY_STATUSES.join(", ")}"
216
231
  end
217
- [response.code.to_i, response.body]
218
- end
219
-
220
- def raise_for(status, envelope, path, fallback = "request failed")
221
- errors = Array(envelope["errors"])
222
- message = errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.first || fallback
223
- error = status.nil? || status == 429 || status >= 500 ? Unavailable : Rejected
224
- raise error.new("Waffo #{path} status=#{status}: #{message}", status: status, errors: errors)
225
- end
226
232
 
227
- def parse(body)
228
- JSON.parse(body.to_s)
229
- rescue JSON::ParserError
230
- nil
233
+ filter = (filter || {}).to_h.transform_keys(&:to_s)
234
+ filter["status"] = { "eq" => status.to_s } if status
235
+ filter["eventType"] = { "eq" => event_type.to_s } if event_type
236
+ filter.empty? ? "" : ", filter: #{self.class.graphql_literal(filter)}"
231
237
  end
232
238
 
233
- def camelize(params)
234
- params.each_with_object({}) do |(key, value), out|
235
- next if value.nil?
239
+ def require_store_id(store_id)
240
+ raise ArgumentError, "store_id is required (WAFFO_STORE_ID)" if blank?(store_id)
236
241
 
237
- out[key.to_s.gsub(/_([a-z\d])/) { Regexp.last_match(1).upcase }] = value
238
- end
242
+ store_id
239
243
  end
240
-
241
- def blank?(value) = value.nil? || value.to_s.strip.empty?
242
-
243
- def present?(value) = !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
244
244
  end
245
245
  end
246
246
  end
@@ -18,6 +18,10 @@ module Waffo
18
18
  # Anything answering `instrument(name, payload) { |payload| ... }`. Defaults to
19
19
  # ActiveSupport::Notifications when it is loaded; nil turns instrumentation off.
20
20
  attr_writer :instrumenter
21
+ # Anything answering `warn(message, callstack)` hears about deprecated fields being read:
22
+ # Waffo::Pancake::Deprecation.default (a once-per-message Kernel#warn) unless set, the
23
+ # gem's ActiveSupport::Deprecation inside Rails; nil stays silent.
24
+ attr_writer :deprecator
21
25
 
22
26
  # "test" or "prod": which platform key verifies webhooks, and which deliveries the Rails
23
27
  # webhook concern accepts. nil tries prod, then test, and accepts both.
@@ -39,6 +43,7 @@ module Waffo
39
43
  @webhook_tolerance = Webhook::DEFAULT_TOLERANCE
40
44
  @webhook_future_tolerance = Webhook::DEFAULT_FUTURE_TOLERANCE
41
45
  @instrumenter = :default
46
+ @deprecator = :default
42
47
  end
43
48
 
44
49
  def environment=(value)
@@ -57,6 +62,10 @@ module Waffo
57
62
  defined?(::ActiveSupport::Notifications) ? ::ActiveSupport::Notifications : nil
58
63
  end
59
64
 
65
+ def deprecator
66
+ @deprecator == :default ? Deprecation.default : @deprecator
67
+ end
68
+
60
69
  # What a client needs; a store id is only checked by the webhook concern.
61
70
  def configured?
62
71
  !blank?(merchant_id) && !blank?(private_key)
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "openssl"
6
+ require "time"
7
+ require "uri"
8
+
9
+ module Waffo
10
+ module Pancake
11
+ # One POST to Waffo: HTTP, instrumentation, the log line and envelope parsing. It knows
12
+ # nothing about authentication, so all three entry points share it.
13
+ #
14
+ # Invariant: the body and the headers are never logged or instrumented, because they hold
15
+ # credentials and buyer data.
16
+ class Connection
17
+ NETWORK_ERRORS = [
18
+ Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
19
+ ].freeze
20
+
21
+ Response = Struct.new(:status, :envelope, :headers)
22
+
23
+ # `transport` takes the URI, the headers and the JSON body and answers `[status, body]` or
24
+ # `[status, body, headers]`.
25
+ def initialize(base_url:, timeout:, logger: nil, instrumenter: nil, transport: nil)
26
+ @base_url = base_url.to_s.chomp("/")
27
+ @timeout = timeout
28
+ @logger = logger
29
+ @instrumenter = instrumenter
30
+ @transport = transport || method(:http_post)
31
+ end
32
+
33
+ attr_reader :base_url
34
+
35
+ def post(path, headers, json)
36
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
+ status, body, response_headers = instrument("request.waffo_pancake", method: "POST", path: path) do |payload|
38
+ call_transport(URI("#{@base_url}#{path}"), headers, json).tap { |answer| payload[:status] = answer.first }
39
+ end
40
+ elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(2)
41
+ @logger&.info("[Waffo] POST #{path} status=#{status.inspect} in #{elapsed}s")
42
+
43
+ raise Unavailable.new("Waffo #{path} unreachable: #{body}", status: nil) if status.nil?
44
+
45
+ envelope = parse(body)
46
+ response = Response.new(status, envelope.is_a?(Hash) ? envelope : {}, response_headers || {})
47
+ fail_with(path, response, "non-JSON response") unless envelope.is_a?(Hash)
48
+
49
+ warn_about(path, response)
50
+ response
51
+ end
52
+
53
+ def fail_with(path, response, fallback = "request failed")
54
+ errors = Array(response.envelope["errors"])
55
+ message = errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.first || fallback
56
+ status = response.status
57
+ error = status == 429 || status >= 500 ? Unavailable : Rejected
58
+ raise error.new("Waffo #{path} status=#{status}: #{message}", status: status, errors: errors,
59
+ retry_after: retry_after(response.headers))
60
+ end
61
+
62
+ private
63
+ def warn_about(path, response)
64
+ warnings = response.envelope["warnings"]
65
+ return if warnings.nil? || warnings.empty?
66
+
67
+ @logger&.warn("[Waffo] POST #{path} warnings=#{warnings.size}")
68
+ end
69
+
70
+ # Seconds to wait, from delay-seconds or an HTTP-date. Nil when the header is missing or
71
+ # unusable, so a caller falls back to its own backoff instead of retrying at once.
72
+ def retry_after(headers)
73
+ _, value = headers.find { |name, _| name.to_s.casecmp?("retry-after") }
74
+ value = value.first if value.is_a?(Array)
75
+ value = value.to_s.strip
76
+ return if value.empty?
77
+ return value.to_i if value.match?(/\A\d+\z/)
78
+
79
+ [(Time.httpdate(value) - Time.now).ceil, 0].max
80
+ rescue ArgumentError
81
+ nil
82
+ end
83
+
84
+ def instrument(name, payload, &block)
85
+ return yield(payload) unless @instrumenter
86
+
87
+ @instrumenter.instrument(name, payload, &block)
88
+ end
89
+
90
+ def call_transport(uri, headers, json)
91
+ @transport.call(uri, headers, json)
92
+ rescue *NETWORK_ERRORS => e
93
+ [nil, "#{e.class}: #{e.message}"]
94
+ end
95
+
96
+ def http_post(uri, headers, json)
97
+ request = Net::HTTP::Post.new(uri, headers)
98
+ request.body = json
99
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
100
+ read_timeout: @timeout, write_timeout: @timeout) do |http|
101
+ http.request(request)
102
+ end
103
+ [response.code.to_i, response.body, response.to_hash]
104
+ end
105
+
106
+ def parse(body)
107
+ JSON.parse(body.to_s)
108
+ rescue JSON::ParserError
109
+ nil
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Waffo
6
+ module Pancake
7
+ # Sends a customer who is signed in on your site into the store's Waffo customer portal,
8
+ # with no email code:
9
+ #
10
+ # class WaffoPortalController < ApplicationController
11
+ # include Waffo::Pancake::CustomerPortalController
12
+ # before_action :authenticate_user! # your own sign-in
13
+ #
14
+ # def show
15
+ # redirect_to_waffo_customer_portal(buyer_identity: current_user.id)
16
+ # end
17
+ # end
18
+ #
19
+ # Link to the route with a plain link (a prefetch would make links nobody clicked). Each
20
+ # request gets a new portal link, answered with a 302, `Cache-Control: no-store` and
21
+ # `Referrer-Policy: no-referrer`; the Railtie keeps the URL out of the request log. When
22
+ # Waffo cannot make a link, the answer is a 502 and the error is logged without the URL.
23
+ module CustomerPortalController
24
+ extend ActiveSupport::Concern
25
+
26
+ NO_STORE_HEADERS = { "Cache-Control" => "no-store", "Referrer-Policy" => "no-referrer" }.freeze
27
+
28
+ private
29
+ # `buyer_identity` is the one given at authenticated checkout and must come from your
30
+ # session, never from params. `store_id` defaults to the configured store.
31
+ def redirect_to_waffo_customer_portal(buyer_identity:, store_id: nil, client: nil)
32
+ response.headers.merge!(NO_STORE_HEADERS)
33
+ store_id ||= Waffo::Pancake.configuration.store_id
34
+ link = (client || Waffo::Pancake.client).create_customer_portal_link(buyer_identity: buyer_identity,
35
+ store_id: store_id)
36
+ redirect_to link.fetch("portalUrl"), allow_other_host: true, status: :found
37
+ rescue Waffo::Pancake::Error => e
38
+ (Waffo::Pancake.configuration.logger || logger)&.error("[Waffo] customer portal link failed: #{e.message}")
39
+ head :bad_gateway
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "requests"
4
+ require_relative "api/customer_actions"
5
+
6
+ module Waffo
7
+ module Pancake
8
+ # The API as one customer, authenticated with a session token from
9
+ # Client#issue_session_token. Use Client#customer to build one.
10
+ #
11
+ # A token is a bearer credential and is not bound to an environment, so X-Environment is
12
+ # required. `inspect` hides the token, so it cannot reach a log through an error report.
13
+ class CustomerSession
14
+ include Requests
15
+ include API::CustomerActions
16
+
17
+ attr_reader :environment
18
+
19
+ def initialize(token:, environment:, connection:)
20
+ raise ConfigurationError, "Missing session token" if token.to_s.strip.empty?
21
+
22
+ @token = token
23
+ @environment = Requests.environment!(environment)
24
+ @connection = connection
25
+ end
26
+
27
+ # `language` is required: without it Waffo answers 400 "Invalid input" and does not name
28
+ # the field. buyer_email: is required unless the checkout session already has one. Optional:
29
+ # user_terminal ("web" or "app").
30
+ def create_onetime_order(checkout_session_id:, billing_detail:, language:, **params)
31
+ create_order("/v1/actions/onetime-order/create-order", checkout_session_id, billing_detail, language, params)
32
+ end
33
+
34
+ def create_subscription_order(checkout_session_id:, billing_detail:, language:, **params)
35
+ create_order("/v1/actions/subscription-order/create-order", checkout_session_id, billing_detail, language, params)
36
+ end
37
+
38
+ def inspect = "#<#{self.class} environment=#{@environment.inspect}>"
39
+
40
+ private
41
+ attr_reader :connection
42
+
43
+ def create_order(path, checkout_session_id, billing_detail, language, params)
44
+ raise ArgumentError, "language is required" if blank?(language)
45
+
46
+ action(path, checkout_session_id: checkout_session_id, billing_detail: billing_detail, language: language,
47
+ **params)
48
+ end
49
+
50
+ def auth_headers(_path, _json)
51
+ { "Authorization" => "Bearer #{@token}", "X-Environment" => @environment }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module Waffo
6
+ module Pancake
7
+ # Reports the use of fields Waffo has deprecated. The reporter is `configuration.deprecator`:
8
+ # anything answering `warn(message, callstack)`, such as ActiveSupport::Deprecation (the
9
+ # Railtie sets one up) or the Warner below, the default outside Rails. Set it to nil to stay
10
+ # silent.
11
+ module Deprecation
12
+ # Writes each distinct message once per process to $stderr through Kernel#warn, with the
13
+ # line that read the field.
14
+ class Warner
15
+ def initialize
16
+ @seen = Set.new
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ def warn(message, callstack = nil)
21
+ return unless @mutex.synchronize { @seen.add?(message) }
22
+
23
+ location = Array(callstack).first
24
+ Kernel.warn("DEPRECATION WARNING: #{message}#{" (called from #{location})" if location} [waffo-pancake]")
25
+ end
26
+ end
27
+
28
+ @default = Warner.new
29
+
30
+ class << self
31
+ # The Warner shared by the process, so a message is not repeated per configuration.
32
+ attr_reader :default
33
+
34
+ def warn(message, callstack = caller_locations(1))
35
+ Pancake.configuration.deprecator&.warn(message, callstack)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end