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/errors.rb
CHANGED
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
module Waffo
|
|
4
4
|
module Pancake
|
|
5
5
|
# Base class. `status` is the HTTP status (nil when the request never got an answer) and
|
|
6
|
-
# `errors` the envelope's `errors` array, as Waffo sent it.
|
|
6
|
+
# `errors` the envelope's `errors` array, as Waffo sent it. `retry_after` is the seconds from
|
|
7
|
+
# the Retry-After header, nil when Waffo sent none.
|
|
7
8
|
class Error < StandardError
|
|
8
|
-
attr_reader :status, :errors
|
|
9
|
+
attr_reader :status, :errors, :retry_after
|
|
9
10
|
|
|
10
|
-
def initialize(message = nil, status: nil, errors: [])
|
|
11
|
+
def initialize(message = nil, status: nil, errors: [], retry_after: nil)
|
|
11
12
|
super(message)
|
|
12
13
|
@status = status
|
|
13
14
|
@errors = errors
|
|
15
|
+
@retry_after = retry_after
|
|
14
16
|
end
|
|
15
17
|
end
|
|
16
18
|
|
|
@@ -11,14 +11,28 @@ module Waffo
|
|
|
11
11
|
# 3. config.waffo_pancake.* in config/application.rb or an environment file
|
|
12
12
|
# 4. Waffo::Pancake.configure in config/initializers
|
|
13
13
|
#
|
|
14
|
-
# The logger defaults to Rails.logger,
|
|
14
|
+
# The logger defaults to Rails.logger, `private_key` is filtered from logs, and so is a
|
|
15
|
+
# redirect to a URL carrying a session token (`#token=`, authenticated checkout and the
|
|
16
|
+
# customer portal). Deprecated webhook fields are reported through `Railtie.deprecator`,
|
|
17
|
+
# registered as `Rails.application.deprecators[:waffo_pancake]`.
|
|
15
18
|
class Railtie < ::Rails::Railtie
|
|
16
19
|
CREDENTIAL_KEYS = %i[merchant_id private_key store_id environment webhook_public_key].freeze
|
|
17
20
|
|
|
21
|
+
TOKEN_FRAGMENT = "#token="
|
|
22
|
+
|
|
18
23
|
config.waffo_pancake = ActiveSupport::OrderedOptions.new
|
|
19
24
|
|
|
25
|
+
def self.deprecator
|
|
26
|
+
@deprecator ||= ActiveSupport::Deprecation.new("1.0", "waffo-pancake")
|
|
27
|
+
end
|
|
28
|
+
|
|
20
29
|
initializer "waffo_pancake.filter_parameters" do |app|
|
|
21
30
|
app.config.filter_parameters |= [:private_key]
|
|
31
|
+
app.config.filter_redirect |= [TOKEN_FRAGMENT] if app.config.respond_to?(:filter_redirect)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
initializer "waffo_pancake.deprecator" do |app|
|
|
35
|
+
app.deprecators[:waffo_pancake] = Railtie.deprecator if app.respond_to?(:deprecators)
|
|
22
36
|
end
|
|
23
37
|
|
|
24
38
|
initializer "waffo_pancake.configuration" do |app|
|
|
@@ -31,6 +45,7 @@ module Waffo
|
|
|
31
45
|
|
|
32
46
|
app.config.waffo_pancake.each { |key, value| config.public_send(:"#{key}=", value) }
|
|
33
47
|
config.logger ||= ::Rails.logger
|
|
48
|
+
config.deprecator = Railtie.deprecator unless app.config.waffo_pancake.key?(:deprecator)
|
|
34
49
|
end
|
|
35
50
|
end
|
|
36
51
|
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Waffo
|
|
6
|
+
module Pancake
|
|
7
|
+
# Sends an explicit JSON null in an update, which clears the field. A plain nil is dropped,
|
|
8
|
+
# so a variable that happens to be nil can never erase data at Waffo.
|
|
9
|
+
NULL = Object.new
|
|
10
|
+
class << NULL
|
|
11
|
+
def to_json(*) = "null"
|
|
12
|
+
def inspect = "Waffo::Pancake::NULL"
|
|
13
|
+
end
|
|
14
|
+
NULL.freeze
|
|
15
|
+
|
|
16
|
+
# The request logic shared by Client, CustomerSession and StoreCheckout. An includer defines
|
|
17
|
+
# `auth_headers(path, json)` and `connection`.
|
|
18
|
+
#
|
|
19
|
+
# Waffo refuses a request that carries two authentication methods, so an includer sends the
|
|
20
|
+
# headers of one method only.
|
|
21
|
+
module Requests
|
|
22
|
+
IDEMPOTENCY_KEY = /\A[A-Za-z0-9_-]{1,256}\z/
|
|
23
|
+
|
|
24
|
+
# Any action endpoint. Returns the envelope's `data`, plus a "warnings" key when Waffo sent
|
|
25
|
+
# any. Raises Rejected or Unavailable on `errors` or a non-2xx status.
|
|
26
|
+
#
|
|
27
|
+
# Top-level keys are sent in camelCase and nil values are dropped; use NULL to clear a field.
|
|
28
|
+
# `idempotency_key:` is sent as X-Idempotency-Key. Waffo keeps the answer for 24 hours.
|
|
29
|
+
def action(path, params = {}, **named)
|
|
30
|
+
params = params.merge(named)
|
|
31
|
+
key = params.delete(:idempotency_key)
|
|
32
|
+
response = post(path, camelize(params), idempotency_headers(key))
|
|
33
|
+
succeeded = !present?(response.envelope["errors"]) && (200..299).cover?(response.status)
|
|
34
|
+
connection.fail_with(path, response) unless succeeded
|
|
35
|
+
|
|
36
|
+
with_warnings(response.envelope)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns `data`. Raises when the response carries `errors` and no data, or on a non-2xx
|
|
40
|
+
# status. Partial data comes back as is, because the fields Waffo did answer are still valid.
|
|
41
|
+
def graphql(query, variables = {})
|
|
42
|
+
path = "/v1/graphql"
|
|
43
|
+
response = post(path, { "query" => query, "variables" => variables })
|
|
44
|
+
envelope = response.envelope
|
|
45
|
+
failed = (present?(envelope["errors"]) && envelope["data"].nil?) || !(200..299).cover?(response.status)
|
|
46
|
+
connection.fail_with(path, response) if failed
|
|
47
|
+
|
|
48
|
+
envelope["data"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.environment!(value)
|
|
52
|
+
value = value.to_s.strip.downcase
|
|
53
|
+
return value if Configuration::ENVIRONMENTS.include?(value)
|
|
54
|
+
|
|
55
|
+
raise ConfigurationError, "environment must be test or prod, got #{value.inspect}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
def post(path, params, extra_headers = {})
|
|
60
|
+
json = JSON.generate(params)
|
|
61
|
+
headers = { "Content-Type" => "application/json" }.merge(auth_headers(path, json)).merge(extra_headers)
|
|
62
|
+
connection.post(path, headers, json)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def idempotency_headers(key)
|
|
66
|
+
return {} if key.nil?
|
|
67
|
+
return { "X-Idempotency-Key" => key } if key.is_a?(String) && key.match?(IDEMPOTENCY_KEY)
|
|
68
|
+
|
|
69
|
+
raise ArgumentError, "idempotency_key must be 1 to 256 characters from A-Z a-z 0-9 _ -"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def with_warnings(envelope)
|
|
73
|
+
data = envelope["data"] || {}
|
|
74
|
+
warnings = envelope["warnings"]
|
|
75
|
+
data.is_a?(Hash) && present?(warnings) ? data.merge("warnings" => warnings) : data
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def camelize(params)
|
|
79
|
+
params.each_with_object({}) do |(key, value), out|
|
|
80
|
+
next if value.nil?
|
|
81
|
+
|
|
82
|
+
out[key.to_s.gsub(/_([a-z\d])/) { Regexp.last_match(1).upcase }] = value.equal?(NULL) ? nil : value
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def blank?(value) = value.nil? || value.to_s.strip.empty?
|
|
87
|
+
|
|
88
|
+
def present?(value) = !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "requests"
|
|
4
|
+
require_relative "connection"
|
|
5
|
+
|
|
6
|
+
module Waffo
|
|
7
|
+
module Pancake
|
|
8
|
+
# Anonymous storefront access, authenticated with only the store's public slug. It holds no
|
|
9
|
+
# secret, so it suits code that cannot keep an API key.
|
|
10
|
+
#
|
|
11
|
+
# storefront = Waffo::Pancake::StoreCheckout.new(store_slug: "my-store", environment: "test")
|
|
12
|
+
# storefront.create_checkout_session(product_id: "PROD_...", currency: "USD")
|
|
13
|
+
class StoreCheckout
|
|
14
|
+
include Requests
|
|
15
|
+
|
|
16
|
+
attr_reader :store_slug, :environment
|
|
17
|
+
|
|
18
|
+
def initialize(store_slug:, config: Pancake.configuration, environment: config.environment,
|
|
19
|
+
base_url: config.base_url, timeout: config.timeout, logger: config.logger,
|
|
20
|
+
instrumenter: config.instrumenter, transport: nil)
|
|
21
|
+
raise ConfigurationError, "Missing store_slug" if store_slug.to_s.strip.empty?
|
|
22
|
+
|
|
23
|
+
@store_slug = store_slug
|
|
24
|
+
@environment = Requests.environment!(environment)
|
|
25
|
+
@connection = Connection.new(base_url: base_url, timeout: timeout, logger: logger, instrumenter: instrumenter,
|
|
26
|
+
transport: transport)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create_checkout_session(product_id:, currency:, **params)
|
|
30
|
+
action("/v1/actions/checkout/create-session", product_id: product_id, currency: currency, **params)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
attr_reader :connection
|
|
35
|
+
|
|
36
|
+
def auth_headers(_path, _json)
|
|
37
|
+
{ "X-Store-Slug" => @store_slug, "X-Environment" => @environment }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -46,7 +46,8 @@ module Waffo
|
|
|
46
46
|
{ "CONTENT_TYPE" => "application/json", Webhook::SIGNATURE_HEADER => waffo_webhook_signature(body, **options) }
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
# A webhook envelope in Waffo's shape
|
|
49
|
+
# A webhook envelope in Waffo's shape, as a plain Hash to JSON.generate and sign.
|
|
50
|
+
# `mode` and `store_id` default to the configuration.
|
|
50
51
|
def waffo_webhook_event(event_type, data: {}, id: SecureRandom.uuid, mode: nil, store_id: nil)
|
|
51
52
|
config = Waffo::Pancake.configuration
|
|
52
53
|
{
|
|
@@ -70,17 +71,31 @@ module Waffo
|
|
|
70
71
|
Waffo::Pancake.client = previous
|
|
71
72
|
end
|
|
72
73
|
|
|
73
|
-
# Stands in for Waffo::Pancake::Client. Answers subscription_order from `orders
|
|
74
|
-
# an order's status on cancel / reactivate the
|
|
75
|
-
# in `calls` as [method, arguments]. Set
|
|
74
|
+
# Stands in for Waffo::Pancake::Client. Answers subscription_order from `orders` and
|
|
75
|
+
# webhook_deliveries from `deliveries`, moves an order's status on cancel / reactivate the
|
|
76
|
+
# way Waffo does, and records every call in `calls` as [method, arguments]. Set
|
|
77
|
+
# `fail_with` to an error to make calls raise it.
|
|
76
78
|
class FakeClient
|
|
77
79
|
attr_reader :orders, :calls
|
|
78
|
-
attr_accessor :fail_with, :checkout_url
|
|
80
|
+
attr_accessor :fail_with, :checkout_url, :portal_url, :deliveries
|
|
79
81
|
|
|
80
82
|
def initialize(orders = {})
|
|
81
83
|
@orders = orders.transform_keys(&:to_s)
|
|
82
84
|
@calls = []
|
|
83
85
|
@checkout_url = "https://pancake.waffo.ai/store/test/checkout/cs_test"
|
|
86
|
+
@portal_url = "https://pancake.waffo.ai/consumer/portal/store/STO_test"
|
|
87
|
+
@deliveries = []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def create_customer_portal_link(**params)
|
|
91
|
+
record(:create_customer_portal_link, params)
|
|
92
|
+
{ "portalUrl" => "#{portal_url}#token=test_token", "expiresAt" => (Time.now + 900).utc.iso8601 }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def webhook_deliveries(**params)
|
|
96
|
+
record(:webhook_deliveries, params)
|
|
97
|
+
status = params[:status]&.to_s
|
|
98
|
+
status ? deliveries.select { |delivery| delivery["status"] == status } : deliveries.dup
|
|
84
99
|
end
|
|
85
100
|
|
|
86
101
|
def subscription_order(order_id)
|
|
@@ -100,13 +115,13 @@ module Waffo
|
|
|
100
115
|
"expiresAt" => (Time.now + 2700).utc.iso8601 }
|
|
101
116
|
end
|
|
102
117
|
|
|
103
|
-
def cancel_subscription(order_id)
|
|
104
|
-
record(:cancel_subscription, order_id)
|
|
118
|
+
def cancel_subscription(order_id, **params)
|
|
119
|
+
record(:cancel_subscription, order_id, *optional(params))
|
|
105
120
|
change_status(order_id, "canceling")
|
|
106
121
|
end
|
|
107
122
|
|
|
108
|
-
def reactivate_subscription(order_id)
|
|
109
|
-
record(:reactivate_subscription, order_id)
|
|
123
|
+
def reactivate_subscription(order_id, **params)
|
|
124
|
+
record(:reactivate_subscription, order_id, *optional(params))
|
|
110
125
|
change_status(order_id, "active")
|
|
111
126
|
end
|
|
112
127
|
|
|
@@ -115,19 +130,41 @@ module Waffo
|
|
|
115
130
|
{}
|
|
116
131
|
end
|
|
117
132
|
|
|
118
|
-
def action(path, params)
|
|
119
|
-
record(:action, path, params)
|
|
133
|
+
def action(path, params = {}, **named)
|
|
134
|
+
record(:action, path, params.merge(named))
|
|
120
135
|
{}
|
|
121
136
|
end
|
|
122
137
|
|
|
138
|
+
# Recorded, then answers as this same fake, so a test reads every call from one list.
|
|
139
|
+
def customer(token, **options)
|
|
140
|
+
record(:customer, token, *optional(options))
|
|
141
|
+
self
|
|
142
|
+
end
|
|
143
|
+
|
|
123
144
|
def called?(name) = calls.any? { |call| call.first == name }
|
|
124
145
|
|
|
146
|
+
# Every other API method records its call and answers empty, so a new endpoint cannot be
|
|
147
|
+
# missing from the fake. Lists answer [], counts 0, reads nil, writes {}.
|
|
148
|
+
answers = API::Queries.empty_answers.merge(webhook_deliveries_count: 0)
|
|
149
|
+
api_methods = [API::CustomerActions, API::StoreActions, API::ProductActions, API::PromotionActions,
|
|
150
|
+
API::Queries].flat_map(&:public_instance_methods)
|
|
151
|
+
client_only = %i[issue_session_token create_authenticated_plan_change webhook_deliveries_count
|
|
152
|
+
create_onetime_order create_subscription_order]
|
|
153
|
+
(api_methods + client_only).uniq.reject { |name| method_defined?(name) }.each do |name|
|
|
154
|
+
define_method(name) do |*arguments, **params|
|
|
155
|
+
record(name, *arguments, *optional(params))
|
|
156
|
+
answers.fetch(name, {})
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
125
160
|
private
|
|
126
161
|
def record(name, *arguments)
|
|
127
162
|
calls << [name, *arguments]
|
|
128
163
|
raise fail_with if fail_with
|
|
129
164
|
end
|
|
130
165
|
|
|
166
|
+
def optional(params) = params.empty? ? [] : [params]
|
|
167
|
+
|
|
131
168
|
def change_status(order_id, status)
|
|
132
169
|
@orders[order_id.to_s]&.merge!("status" => status)
|
|
133
170
|
{ "orderId" => order_id, "status" => status }
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Waffo
|
|
6
|
+
module Pancake
|
|
7
|
+
module Webhook
|
|
8
|
+
PAYMENT_EVENT_TYPES = %w[order.completed subscription.payment_succeeded].freeze
|
|
9
|
+
REFUND_EVENT_TYPES = %w[refund.succeeded refund.failed].freeze
|
|
10
|
+
|
|
11
|
+
# The six `data` fields whose name meant a different thing per event family, with the field
|
|
12
|
+
# that replaces each on payment, refund and subscription events. Waffo keeps sending them
|
|
13
|
+
# until the next major API version, not before DEPRECATED_FIELDS_REMOVAL.
|
|
14
|
+
DEPRECATED_FIELDS = {
|
|
15
|
+
"amount" => { payment: "chargedAmount", refund: "refundedAmount", subscription: "planPrice.total" },
|
|
16
|
+
"total" => { payment: "listPrice.total", refund: "originalPayment.total", subscription: "planPrice.total" },
|
|
17
|
+
"subtotal" => { payment: "listPrice.subtotal", refund: "originalPayment.subtotal",
|
|
18
|
+
subscription: "planPrice.subtotal" },
|
|
19
|
+
"taxAmount" => { payment: "listPrice.taxAmount", refund: "originalPayment.taxAmount",
|
|
20
|
+
subscription: "planPrice.taxAmount" },
|
|
21
|
+
"taxRate" => { payment: "listPrice.taxRate", refund: "originalPayment.taxRate",
|
|
22
|
+
subscription: "planPrice.taxRate" },
|
|
23
|
+
"taxName" => { payment: "listPrice.taxName", refund: "originalPayment.taxName",
|
|
24
|
+
subscription: "planPrice.taxName" }
|
|
25
|
+
}.freeze
|
|
26
|
+
DEPRECATED_FIELDS_REMOVAL = "2027-09-20"
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# :payment (one charge), :refund, :subscription (a subscription's status), or nil.
|
|
31
|
+
def family(event_type)
|
|
32
|
+
event_type = event_type.to_s
|
|
33
|
+
return :payment if PAYMENT_EVENT_TYPES.include?(event_type)
|
|
34
|
+
return :refund if REFUND_EVENT_TYPES.include?(event_type)
|
|
35
|
+
|
|
36
|
+
:subscription if event_type.start_with?("subscription.")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A delivery as a Hash, with readers for the envelope. `data` is an EventData, which warns
|
|
40
|
+
# through Waffo::Pancake::Deprecation when a deprecated field is read.
|
|
41
|
+
#
|
|
42
|
+
# event = Waffo::Pancake::Webhook::Event.wrap(stored_payload) # Hash or JSON String
|
|
43
|
+
# event.data.charged_amount # => "9.00"
|
|
44
|
+
class Event < Hash
|
|
45
|
+
# `payload` is a Hash (string or symbol keys) or its JSON. Use it on a delivery you stored
|
|
46
|
+
# or read back with Client#webhook_deliveries; it does not check a signature.
|
|
47
|
+
def self.wrap(payload)
|
|
48
|
+
return payload if payload.is_a?(Event)
|
|
49
|
+
|
|
50
|
+
payload = JSON.parse(payload) if payload.is_a?(String)
|
|
51
|
+
raise ArgumentError, "webhook event must be a Hash, got #{payload.class}" unless payload.respond_to?(:to_hash)
|
|
52
|
+
|
|
53
|
+
event = new.replace(payload.to_hash.transform_keys(&:to_s))
|
|
54
|
+
event["data"] = EventData.new(event["data"], event["eventType"]) if event["data"].respond_to?(:to_hash)
|
|
55
|
+
event
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The delivery id: the same on every retry, so deduplicate on it.
|
|
59
|
+
def id = self["id"]
|
|
60
|
+
def event_type = self["eventType"]
|
|
61
|
+
def event_id = self["eventId"]
|
|
62
|
+
def store_id = self["storeId"]
|
|
63
|
+
def mode = self["mode"]
|
|
64
|
+
def test? = mode == "test"
|
|
65
|
+
def timestamp = self["timestamp"]
|
|
66
|
+
def data = self["data"] || EventData.new({}, event_type)
|
|
67
|
+
def family = Webhook.family(event_type)
|
|
68
|
+
def payment? = family == :payment
|
|
69
|
+
def refund? = family == :refund
|
|
70
|
+
def subscription? = family == :subscription
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The `data` of a delivery. Reading `amount`, `total`, `subtotal`, `taxAmount`, `taxRate` or
|
|
74
|
+
# `taxName` with [], fetch or dig still answers the value, and reports the field to read
|
|
75
|
+
# instead. Iterating, serializing and `key?` report nothing.
|
|
76
|
+
class EventData < Hash
|
|
77
|
+
attr_reader :event_type
|
|
78
|
+
|
|
79
|
+
def initialize(fields = {}, event_type = nil)
|
|
80
|
+
super()
|
|
81
|
+
replace(fields.to_hash.transform_keys(&:to_s))
|
|
82
|
+
@event_type = event_type
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def [](key)
|
|
86
|
+
deprecated_read(key)
|
|
87
|
+
super
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def fetch(key, *default, &block)
|
|
91
|
+
deprecated_read(key)
|
|
92
|
+
super
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def dig(key, *keys)
|
|
96
|
+
deprecated_read(key)
|
|
97
|
+
super
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def order_id = self["orderId"]
|
|
101
|
+
def currency = self["currency"]
|
|
102
|
+
|
|
103
|
+
# What this charge actually took, after prorated credits, trials and zero-amount card
|
|
104
|
+
# checks. Payment events only; nil when the payment channel reported no amount.
|
|
105
|
+
def charged_amount = self["chargedAmount"]
|
|
106
|
+
|
|
107
|
+
# What this refund returned. Refund events only; nil when the channel reported no amount.
|
|
108
|
+
def refunded_amount = self["refundedAmount"]
|
|
109
|
+
|
|
110
|
+
# What the refunded payment actually collected: the most a refund can reach. Refund
|
|
111
|
+
# events only; nil when the channel reported no amount for that payment.
|
|
112
|
+
def original_charged_amount = self["originalChargedAmount"]
|
|
113
|
+
|
|
114
|
+
# Breakdowns (total, subtotal, taxAmount, taxRate, taxName), each on one event family.
|
|
115
|
+
def list_price = self["listPrice"]
|
|
116
|
+
def original_payment = self["originalPayment"]
|
|
117
|
+
def plan_price = self["planPrice"]
|
|
118
|
+
|
|
119
|
+
# The breakdown this event's figures refer to, whatever its family.
|
|
120
|
+
def price = list_price || original_payment || plan_price
|
|
121
|
+
|
|
122
|
+
# The payment channel's billing period: 1 for the first, N for the Nth renewal. A failed
|
|
123
|
+
# charge uses a period too; 0 is authorized but not charged yet. On refunds, the period of
|
|
124
|
+
# the refunded charge. Nil on one-time orders and on older subscriptions. Not unique per
|
|
125
|
+
# event: deduplicate on the event id.
|
|
126
|
+
def period_number = self["periodNumber"]
|
|
127
|
+
def first_period? = period_number == 1
|
|
128
|
+
def renewal? = period_number.is_a?(Integer) && period_number > 1
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
def deprecated_read(key)
|
|
132
|
+
replacements = DEPRECATED_FIELDS[key.to_s]
|
|
133
|
+
return unless replacements
|
|
134
|
+
|
|
135
|
+
Deprecation.warn(deprecation_message(key.to_s, replacements), caller_locations(2))
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def deprecation_message(key, replacements)
|
|
139
|
+
family = Webhook.family(event_type)
|
|
140
|
+
instead = if family
|
|
141
|
+
reader(replacements.fetch(family))
|
|
142
|
+
else
|
|
143
|
+
replacements.map { |name, field| "#{reader(field)} on #{name} events" }.join(", ")
|
|
144
|
+
end
|
|
145
|
+
"Waffo webhook data[#{key.inspect}] is deprecated#{" on #{event_type}" if family}; read #{instead} " \
|
|
146
|
+
"instead. Waffo removes it in its next major API version, not before #{DEPRECATED_FIELDS_REMOVAL}."
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def reader(field)
|
|
150
|
+
parts = field.split(".")
|
|
151
|
+
parts.one? ? "data[#{field.inspect}]" : "data.dig(#{parts.map(&:inspect).join(", ")})"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -10,7 +10,8 @@ module Waffo
|
|
|
10
10
|
#
|
|
11
11
|
# event = Waffo::Pancake::Webhook.verify(request.raw_post, request.headers["X-Waffo-Signature"],
|
|
12
12
|
# environment: "prod")
|
|
13
|
-
# event["eventType"]
|
|
13
|
+
# event["eventType"] # => "subscription.activated"
|
|
14
|
+
# event.data.period_number # => 1
|
|
14
15
|
#
|
|
15
16
|
# The header is `t=<unix ms>,v1=<base64>`: an RSA-SHA256 signature over "<t>.<raw body>".
|
|
16
17
|
# Pass the body exactly as received; a re-serialized JSON will not verify.
|
|
@@ -24,7 +25,7 @@ module Waffo
|
|
|
24
25
|
|
|
25
26
|
ENVIRONMENTS = %w[test prod].freeze
|
|
26
27
|
|
|
27
|
-
# Waffo's platform keys, as embedded in @waffo/pancake-ts 0.
|
|
28
|
+
# Waffo's platform keys, as embedded in @waffo/pancake-ts 0.26.0.
|
|
28
29
|
PUBLIC_KEYS = {
|
|
29
30
|
"test" => <<~PEM,
|
|
30
31
|
-----BEGIN PUBLIC KEY-----
|
|
@@ -60,7 +61,7 @@ module Waffo
|
|
|
60
61
|
|
|
61
62
|
module_function
|
|
62
63
|
|
|
63
|
-
# Returns the parsed event. Raises InvalidSignature.
|
|
64
|
+
# Returns the parsed event, a Webhook::Event (a Hash). Raises InvalidSignature.
|
|
64
65
|
#
|
|
65
66
|
# environment: "test" or "prod" uses that environment's key; nil tries prod, then test.
|
|
66
67
|
# public_key: a key to use instead (skips the lookup and `environment`).
|
|
@@ -74,7 +75,8 @@ module Waffo
|
|
|
74
75
|
keys = public_key ? [Keys.public_key(public_key)] : keys_for(environment)
|
|
75
76
|
raise InvalidSignature, "Invalid webhook signature" unless keys.any? { |key| valid?(key, signature, input) }
|
|
76
77
|
|
|
77
|
-
JSON.parse(payload.to_s)
|
|
78
|
+
event = JSON.parse(payload.to_s)
|
|
79
|
+
event.is_a?(Hash) ? Event.wrap(event) : event
|
|
78
80
|
rescue JSON::ParserError
|
|
79
81
|
raise InvalidSignature, "Webhook body is not JSON"
|
|
80
82
|
end
|
data/lib/waffo/pancake.rb
CHANGED
|
@@ -4,7 +4,10 @@ require_relative "pancake/version"
|
|
|
4
4
|
require_relative "pancake/errors"
|
|
5
5
|
require_relative "pancake/keys"
|
|
6
6
|
require_relative "pancake/client"
|
|
7
|
+
require_relative "pancake/store_checkout"
|
|
8
|
+
require_relative "pancake/deprecation"
|
|
7
9
|
require_relative "pancake/webhook"
|
|
10
|
+
require_relative "pancake/webhook/event"
|
|
8
11
|
require_relative "pancake/configuration"
|
|
9
12
|
require_relative "pancake/railtie" if defined?(Rails::Railtie)
|
|
10
13
|
|
|
@@ -13,6 +16,7 @@ module Waffo
|
|
|
13
16
|
module Pancake
|
|
14
17
|
# Need ActiveSupport / Action Pack; loaded on first use.
|
|
15
18
|
autoload :WebhookController, "waffo/pancake/webhook_controller"
|
|
19
|
+
autoload :CustomerPortalController, "waffo/pancake/customer_portal_controller"
|
|
16
20
|
autoload :TestHelpers, "waffo/pancake/test_helpers"
|
|
17
21
|
end
|
|
18
22
|
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.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Good First Issue
|
|
@@ -41,14 +41,26 @@ files:
|
|
|
41
41
|
- lib/generators/waffo_pancake/install/templates/webhook_job.rb.tt
|
|
42
42
|
- lib/generators/waffo_pancake/install/templates/webhooks_controller.rb.tt
|
|
43
43
|
- lib/waffo/pancake.rb
|
|
44
|
+
- lib/waffo/pancake/api/customer_actions.rb
|
|
45
|
+
- lib/waffo/pancake/api/product_actions.rb
|
|
46
|
+
- lib/waffo/pancake/api/promotion_actions.rb
|
|
47
|
+
- lib/waffo/pancake/api/queries.rb
|
|
48
|
+
- lib/waffo/pancake/api/store_actions.rb
|
|
44
49
|
- lib/waffo/pancake/client.rb
|
|
45
50
|
- lib/waffo/pancake/configuration.rb
|
|
51
|
+
- lib/waffo/pancake/connection.rb
|
|
52
|
+
- lib/waffo/pancake/customer_portal_controller.rb
|
|
53
|
+
- lib/waffo/pancake/customer_session.rb
|
|
54
|
+
- lib/waffo/pancake/deprecation.rb
|
|
46
55
|
- lib/waffo/pancake/errors.rb
|
|
47
56
|
- lib/waffo/pancake/keys.rb
|
|
48
57
|
- lib/waffo/pancake/railtie.rb
|
|
58
|
+
- lib/waffo/pancake/requests.rb
|
|
59
|
+
- lib/waffo/pancake/store_checkout.rb
|
|
49
60
|
- lib/waffo/pancake/test_helpers.rb
|
|
50
61
|
- lib/waffo/pancake/version.rb
|
|
51
62
|
- lib/waffo/pancake/webhook.rb
|
|
63
|
+
- lib/waffo/pancake/webhook/event.rb
|
|
52
64
|
- lib/waffo/pancake/webhook_controller.rb
|
|
53
65
|
homepage: https://github.com/goodfirstissueorg/waffo-pancake-ruby
|
|
54
66
|
licenses:
|