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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +203 -13
- data/lib/generators/waffo_pancake/install/templates/webhook_event.rb.tt +5 -1
- data/lib/generators/waffo_pancake/install/templates/webhook_job.rb.tt +7 -1
- data/lib/waffo/pancake/api/customer_actions.rb +55 -0
- data/lib/waffo/pancake/api/product_actions.rb +64 -0
- data/lib/waffo/pancake/api/promotion_actions.rb +39 -0
- data/lib/waffo/pancake/api/queries.rb +292 -0
- data/lib/waffo/pancake/api/store_actions.rb +64 -0
- data/lib/waffo/pancake/client.rb +140 -140
- data/lib/waffo/pancake/configuration.rb +9 -0
- data/lib/waffo/pancake/connection.rb +113 -0
- data/lib/waffo/pancake/customer_portal_controller.rb +43 -0
- data/lib/waffo/pancake/customer_session.rb +55 -0
- data/lib/waffo/pancake/deprecation.rb +40 -0
- data/lib/waffo/pancake/errors.rb +5 -3
- data/lib/waffo/pancake/railtie.rb +16 -1
- data/lib/waffo/pancake/requests.rb +91 -0
- data/lib/waffo/pancake/store_checkout.rb +41 -0
- data/lib/waffo/pancake/test_helpers.rb +48 -11
- data/lib/waffo/pancake/version.rb +1 -1
- data/lib/waffo/pancake/webhook/event.rb +156 -0
- data/lib/waffo/pancake/webhook.rb +6 -4
- data/lib/waffo/pancake.rb +4 -0
- metadata +13 -1
data/lib/waffo/pancake/client.rb
CHANGED
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
require "base64"
|
|
4
4
|
require "json"
|
|
5
|
-
require "net/http"
|
|
6
5
|
require "openssl"
|
|
7
|
-
require "
|
|
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
|
-
|
|
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]`
|
|
51
|
-
# no answer). `logger` gets one info line per request and
|
|
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
|
-
|
|
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
|
-
@
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
#
|
|
97
|
-
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
# ---
|
|
172
|
+
# --- Webhook deliveries ------------------------------------------------------------
|
|
121
173
|
|
|
122
|
-
#
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
129
|
-
|
|
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
|
-
#
|
|
133
|
-
def
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
173
|
-
|
|
216
|
+
attr_reader :connection
|
|
217
|
+
|
|
218
|
+
def auth_headers(path, json)
|
|
174
219
|
timestamp = Time.now.to_i.to_s
|
|
175
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
234
|
-
|
|
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
|
-
|
|
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
|