waffo-pancake 0.2.0 → 0.3.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 +33 -0
- data/README.md +91 -6
- 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/client.rb +100 -6
- data/lib/waffo/pancake/configuration.rb +9 -0
- data/lib/waffo/pancake/customer_portal_controller.rb +43 -0
- data/lib/waffo/pancake/deprecation.rb +40 -0
- data/lib/waffo/pancake/railtie.rb +16 -1
- data/lib/waffo/pancake/test_helpers.rb +20 -5
- 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 +3 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87fe6008dd468bd2fe340702f5b8a6b5f4ce9141a7b49b9c069cf37062aaca3f
|
|
4
|
+
data.tar.gz: f9d37be2f832780519fdcd3319eb1ac3a96e073c588c153bea72477c4518cd61
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 77f17baee5d56461088b0b3ecc8b948d2d341d35b373ae6845593e5504632a17f995fbcc63d889821341267305edc70c1bbb21088ce6870927ca4a154043d72c
|
|
7
|
+
data.tar.gz: 9e0cb6aae549bc3fd02978fd432252d7d5eefe4018505360284374548158fd62b5c5ea10d59f3c8769a3748a08eca053d3e75ed8c25e5a615bfc739a6d15c635
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- Webhook amount fields (Waffo, 2026-09-20 to 2026-09-22):
|
|
6
|
+
- `Webhook.verify` and `Waffo::Pancake.verify_webhook` return a `Webhook::Event`, a Hash
|
|
7
|
+
subclass, so code that reads it as a Hash keeps working. `Webhook::Event.wrap` makes one from
|
|
8
|
+
a stored payload (Hash or JSON).
|
|
9
|
+
- `event.data` readers for the new fields: `charged_amount`, `refunded_amount`,
|
|
10
|
+
`original_charged_amount`, `list_price`, `original_payment`, `plan_price`, `price`,
|
|
11
|
+
`period_number`, `first_period?`, `renewal?`.
|
|
12
|
+
- Reading a deprecated field (`amount`, `total`, `subtotal`, `taxAmount`, `taxRate`, `taxName`)
|
|
13
|
+
with `[]`, `fetch` or `dig` reports a deprecation warning naming its replacement for that
|
|
14
|
+
event type. `config.deprecator` chooses where it goes: a once-per-message `Kernel#warn` by
|
|
15
|
+
default, `Rails.application.deprecators[:waffo_pancake]` in Rails, nil for none.
|
|
16
|
+
- `Webhook::DEPRECATED_FIELDS`, `PAYMENT_EVENT_TYPES`, `REFUND_EVENT_TYPES` and
|
|
17
|
+
`Webhook.family`.
|
|
18
|
+
- Customer portal sign-in without an email code: `Client#create_customer_portal_link`
|
|
19
|
+
(`issue-session-token` with `purpose: "portal"`), `issue_session_token(purpose:)`, and the
|
|
20
|
+
`Waffo::Pancake::CustomerPortalController` Rails concern, which answers with a 302 and
|
|
21
|
+
`Cache-Control: no-store` / `Referrer-Policy: no-referrer`. The Railtie adds `#token=` to
|
|
22
|
+
`filter_redirect`, so these URLs stay out of the request log.
|
|
23
|
+
- Webhook delivery records: `Client#webhook_deliveries` and `#webhook_deliveries_count`, with
|
|
24
|
+
`status:`, `event_type:`, `filter:`, `limit:` and `offset:`. Waffo has no redelivery API;
|
|
25
|
+
run your handler on `Webhook::Event.wrap(delivery["payload"])` instead.
|
|
26
|
+
- `Client.new(store_id:)`, defaulting to the configured store, for the calls above.
|
|
27
|
+
- `TestHelpers::FakeClient#create_customer_portal_link` and `#webhook_deliveries`.
|
|
28
|
+
- The generated `WaffoWebhookEvent#data` and `#waffo_event` wrap the stored payload; the
|
|
29
|
+
generated job reads the new amount fields.
|
|
30
|
+
|
|
31
|
+
## 0.2.1
|
|
32
|
+
|
|
33
|
+
- `Client#subscription_order` also returns the order's current product as `productId`, read
|
|
34
|
+
from `subscriptionProduct { id }`, so a plan change to another product can be detected.
|
|
35
|
+
|
|
3
36
|
## 0.2.0
|
|
4
37
|
|
|
5
38
|
- `Waffo::Pancake.configure` / `.configuration`: process-wide settings that start from the
|
data/README.md
CHANGED
|
@@ -68,6 +68,34 @@ client.create_checkout_session(product_id: "PROD_...", currency: "USD")
|
|
|
68
68
|
Top-level keyword arguments may be snake_case and are sent as camelCase. Nested hashes
|
|
69
69
|
(`metadata`, `prices`, `billing_detail`'s fields) are sent exactly as given.
|
|
70
70
|
|
|
71
|
+
## Customer portal
|
|
72
|
+
|
|
73
|
+
A customer who is signed in on your site can enter the store's customer portal without an email
|
|
74
|
+
code. Use the `buyer_identity` you gave at authenticated checkout, taken from your own session.
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
link = client.create_customer_portal_link(buyer_identity: current_user.id) # store_id: defaults to WAFFO_STORE_ID
|
|
78
|
+
# => {"portalUrl" => "https://pancake.waffo.ai/consumer/portal/store/STO_...#token=...", "expiresAt" => "..."}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The URL is a bearer credential: make a new one on each click, answer with a 302 and
|
|
82
|
+
`Cache-Control: no-store` / `Referrer-Policy: no-referrer`, and never log, cache or email it.
|
|
83
|
+
The portal opens on the API key's environment. In Rails, the concern does all of that:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class WaffoPortalController < ApplicationController
|
|
87
|
+
include Waffo::Pancake::CustomerPortalController
|
|
88
|
+
before_action :authenticate_user!
|
|
89
|
+
|
|
90
|
+
def show
|
|
91
|
+
redirect_to_waffo_customer_portal(buyer_identity: current_user.id) # 502 if Waffo fails
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Link to that route with a plain `<a>`, so nothing prefetches it. Set a Website in the store
|
|
97
|
+
settings: the portal's return button links only there.
|
|
98
|
+
|
|
71
99
|
## Subscriptions
|
|
72
100
|
|
|
73
101
|
```ruby
|
|
@@ -112,9 +140,10 @@ class WaffoWebhooksController < ActionController::Base
|
|
|
112
140
|
request.headers["X-Waffo-Signature"],
|
|
113
141
|
environment: "prod" # or "test"; omit to try prod, then test
|
|
114
142
|
)
|
|
115
|
-
# Deduplicate on event
|
|
116
|
-
case event
|
|
117
|
-
when "
|
|
143
|
+
# Deduplicate on event.id: a retry redelivers the same id.
|
|
144
|
+
case event.event_type
|
|
145
|
+
when "order.completed" then # record event.data.charged_amount
|
|
146
|
+
when "subscription.activated", "subscription.renewed" then # grant access until data["currentPeriodEnd"]
|
|
118
147
|
when "subscription.canceled" then # revoke access
|
|
119
148
|
end
|
|
120
149
|
head :ok
|
|
@@ -131,12 +160,68 @@ both), or pass `public_key:`. Timestamps may be up to 45 minutes old and 1 minut
|
|
|
131
160
|
|
|
132
161
|
`Waffo::Pancake::Webhook::EVENT_TYPES` lists the event types.
|
|
133
162
|
|
|
163
|
+
### Amount fields
|
|
164
|
+
|
|
165
|
+
`verify` returns a `Waffo::Pancake::Webhook::Event`, a Hash with readers. Its `data` reads
|
|
166
|
+
the amount fields by what they describe:
|
|
167
|
+
|
|
168
|
+
| Reader | Field | Events |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| `charged_amount` | `chargedAmount` | Payment (`order.completed`, `subscription.payment_succeeded`): what the buyer was actually charged, after prorated credits, trials and zero-amount card checks |
|
|
171
|
+
| `list_price` | `listPrice` | Payment: the list price behind the charge (`total`, `subtotal`, `taxAmount`, `taxRate`, `taxName`) |
|
|
172
|
+
| `refunded_amount` | `refundedAmount` | `refund.*`: what this refund returned |
|
|
173
|
+
| `original_charged_amount` | `originalChargedAmount` | `refund.*`: what the refunded payment actually collected, the most a refund can reach |
|
|
174
|
+
| `original_payment` | `originalPayment` | `refund.*`: the refunded payment's list price |
|
|
175
|
+
| `plan_price` | `planPrice` | Other `subscription.*` events: the plan's price for the current period |
|
|
176
|
+
| `period_number`, `first_period?`, `renewal?` | `periodNumber` | Subscription events, `subscription.payment_succeeded` and subscription refunds: 1 for the first period, N for the Nth renewal |
|
|
177
|
+
|
|
178
|
+
`chargedAmount`, `refundedAmount` and `originalChargedAmount` are absent (nil) when the payment
|
|
179
|
+
channel reported no amount. `periodNumber` counts failed charges too, is `0` before a scheduled
|
|
180
|
+
plan change takes effect, and is not a deduplication key.
|
|
181
|
+
|
|
182
|
+
`amount`, `total`, `subtotal`, `taxAmount`, `taxRate` and `taxName` are deprecated: one name meant
|
|
183
|
+
a different amount per event family. Waffo sends them until its next major API version, not
|
|
184
|
+
before 2027-09-20. Since 2026-09-21, `amount` on payment events is the charged amount, so a check
|
|
185
|
+
such as `amount == total` fails there. Reading one of them with `[]`, `fetch` or `dig` still
|
|
186
|
+
returns the value, and reports the field to read instead for that event type:
|
|
187
|
+
|
|
188
|
+
```
|
|
189
|
+
DEPRECATION WARNING: Waffo webhook data["amount"] is deprecated on refund.succeeded; read data["refundedAmount"] instead. ...
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Outside Rails each message is printed once per process with `Kernel#warn`. In Rails it goes to
|
|
193
|
+
`Rails.application.deprecators[:waffo_pancake]`, so `config.active_support.deprecation` applies
|
|
194
|
+
(`:raise` in tests finds every place to change). Set `config.deprecator` to any object answering
|
|
195
|
+
`warn(message, callstack)`, or to nil to turn the warnings off. A stored payload (or one that went
|
|
196
|
+
through a job) is a plain Hash again: `Waffo::Pancake::Webhook::Event.wrap(payload)` brings the
|
|
197
|
+
readers and warnings back.
|
|
198
|
+
|
|
199
|
+
### Delivery records
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
client.webhook_deliveries(status: :failed, limit: 20) # store_id: defaults to WAFFO_STORE_ID
|
|
203
|
+
# => [{"id" => ..., "eventType" => ..., "status" => "failed", "httpStatus" => 500, "attemptCount" => 4,
|
|
204
|
+
# "payload" => ..., "responseBody" => ..., "lastAttemptedAt" => ..., ...}]
|
|
205
|
+
client.webhook_deliveries_count(status: :failed)
|
|
206
|
+
client.webhook_deliveries(event_type: "refund.succeeded", filter: { createdAt: { gte: 1.week.ago } }, offset: 20)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Waffo retries a delivery 3 times and has no manual redelivery. To process a failed one again,
|
|
210
|
+
run your own handler on its payload:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
client.webhook_deliveries(status: :failed).each do |delivery|
|
|
214
|
+
HandleWaffoEventJob.perform_later(Waffo::Pancake::Webhook::Event.wrap(delivery["payload"]))
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
134
218
|
## Rails
|
|
135
219
|
|
|
136
220
|
Inside a Rails app the gem configures itself. Settings are read in this order, the last one
|
|
137
221
|
winning: `WAFFO_*` environment variables, `credentials.waffo`, `config.waffo_pancake`, then
|
|
138
|
-
`Waffo::Pancake.configure` in an initializer. The logger is `Rails.logger`,
|
|
139
|
-
is added to `filter_parameters
|
|
222
|
+
`Waffo::Pancake.configure` in an initializer. The logger is `Rails.logger`, `private_key`
|
|
223
|
+
is added to `filter_parameters`, and redirects to a URL with `#token=` (authenticated checkout,
|
|
224
|
+
customer portal) to `filter_redirect`.
|
|
140
225
|
|
|
141
226
|
```yaml
|
|
142
227
|
# bin/rails credentials:edit
|
|
@@ -165,7 +250,7 @@ class WaffoWebhooksController < ActionController::Base
|
|
|
165
250
|
include Waffo::Pancake::WebhookController
|
|
166
251
|
|
|
167
252
|
def create
|
|
168
|
-
HandleWaffoEventJob.perform_later(waffo_event) # verified
|
|
253
|
+
HandleWaffoEventJob.perform_later(waffo_event) # verified Webhook::Event; wrap it again in the job
|
|
169
254
|
head :ok
|
|
170
255
|
end
|
|
171
256
|
end
|
|
@@ -13,5 +13,9 @@ class WaffoWebhookEvent < ApplicationRecord
|
|
|
13
13
|
[ find_by!(delivery_id: event["id"].to_s), false ]
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
# The stored payload as Waffo::Pancake::Webhook::Event: readers for the amount fields, and a
|
|
17
|
+
# deprecation warning when a field Waffo has deprecated is read.
|
|
18
|
+
def waffo_event = Waffo::Pancake::Webhook::Event.wrap(payload)
|
|
19
|
+
|
|
20
|
+
def data = waffo_event.data
|
|
17
21
|
end
|
|
@@ -6,7 +6,13 @@ class WaffoWebhookJob < ApplicationJob
|
|
|
6
6
|
def perform(event)
|
|
7
7
|
return if event.processed_at
|
|
8
8
|
|
|
9
|
+
# Amounts: event.data.charged_amount on payment events (what the buyer was actually charged,
|
|
10
|
+
# after credits and trials), event.data.refunded_amount on refunds, event.data.plan_price on
|
|
11
|
+
# subscription events. data["amount"] / ["total"] / ["tax*"] are deprecated.
|
|
9
12
|
case event.event_type
|
|
13
|
+
when "order.completed", "subscription.payment_succeeded"
|
|
14
|
+
# Record the receipt: event.data.charged_amount. event.data.first_period? tells the first
|
|
15
|
+
# charge of a subscription from a renewal (event.data.period_number).
|
|
10
16
|
when "subscription.activated", "subscription.renewed", "subscription.recovered", "subscription.uncanceled"
|
|
11
17
|
# Grant access until event.data["currentPeriodEnd"]. To read the order as Waffo has it
|
|
12
18
|
# now, regardless of delivery order: Waffo::Pancake.client.subscription_order(event.order_id)
|
|
@@ -17,7 +23,7 @@ class WaffoWebhookJob < ApplicationJob
|
|
|
17
23
|
when "subscription.past_due"
|
|
18
24
|
# The renewal failed; the payment channel retries once, usually the next day.
|
|
19
25
|
when "refund.succeeded"
|
|
20
|
-
#
|
|
26
|
+
# event.data.refunded_amount, out of event.data.original_charged_amount actually collected.
|
|
21
27
|
end
|
|
22
28
|
|
|
23
29
|
event.update!(processed_at: Time.current)
|
data/lib/waffo/pancake/client.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "base64"
|
|
|
4
4
|
require "json"
|
|
5
5
|
require "net/http"
|
|
6
6
|
require "openssl"
|
|
7
|
+
require "time"
|
|
7
8
|
require "uri"
|
|
8
9
|
|
|
9
10
|
module Waffo
|
|
@@ -30,11 +31,16 @@ module Waffo
|
|
|
30
31
|
subscriptionOrder(id: $id) {
|
|
31
32
|
id storeId status testMode buyerEmail merchantProvidedBuyerIdentity orderMerchantExternalId
|
|
32
33
|
billingPeriod currentPeriodStart currentPeriodEnd canceledAt
|
|
34
|
+
subscriptionProduct { id }
|
|
33
35
|
}
|
|
34
36
|
}
|
|
35
37
|
GRAPHQL
|
|
36
38
|
|
|
37
|
-
|
|
39
|
+
WEBHOOK_DELIVERY_FIELDS = "id storeId eventType eventId payload webhookUrl status httpStatus responseBody " \
|
|
40
|
+
"attemptCount lastAttemptedAt createdAt"
|
|
41
|
+
WEBHOOK_DELIVERY_STATUSES = %w[pending success failed].freeze
|
|
42
|
+
|
|
43
|
+
attr_reader :merchant_id, :base_url, :store_id
|
|
38
44
|
|
|
39
45
|
# The canonical request the gateway checks `X-Signature` against: method, path,
|
|
40
46
|
# timestamp (seconds) and the base64 SHA-256 of the body, one per line.
|
|
@@ -50,12 +56,13 @@ module Waffo
|
|
|
50
56
|
# no answer). `logger` gets one info line per request and `instrumenter` one
|
|
51
57
|
# `request.waffo_pancake` event; neither ever sees the body or the key.
|
|
52
58
|
def initialize(config: Pancake.configuration, merchant_id: config.merchant_id, private_key: config.private_key,
|
|
53
|
-
base_url: config.base_url, timeout: config.timeout, logger: config.logger,
|
|
59
|
+
store_id: config.store_id, base_url: config.base_url, timeout: config.timeout, logger: config.logger,
|
|
54
60
|
instrumenter: config.instrumenter, transport: nil)
|
|
55
61
|
raise ConfigurationError, "Missing merchant_id (WAFFO_MERCHANT_ID)" if blank?(merchant_id)
|
|
56
62
|
raise ConfigurationError, "Missing private_key (WAFFO_PRIVATE_KEY)" if blank?(private_key)
|
|
57
63
|
|
|
58
64
|
@merchant_id = merchant_id
|
|
65
|
+
@store_id = blank?(store_id) ? nil : store_id
|
|
59
66
|
@private_key = Keys.private_key(private_key)
|
|
60
67
|
@base_url = base_url.to_s.chomp("/")
|
|
61
68
|
@timeout = timeout
|
|
@@ -66,11 +73,13 @@ module Waffo
|
|
|
66
73
|
|
|
67
74
|
# --- Checkout ----------------------------------------------------------------------
|
|
68
75
|
|
|
69
|
-
|
|
76
|
+
# `purpose: "portal"` signs the customer into the store's customer portal instead; see
|
|
77
|
+
# create_customer_portal_link.
|
|
78
|
+
def issue_session_token(buyer_identity:, store_id: nil, product_id: nil, purpose: nil)
|
|
70
79
|
raise ArgumentError, "store_id or product_id is required" if store_id.nil? && product_id.nil?
|
|
71
80
|
|
|
72
81
|
action("/v1/actions/auth/issue-session-token",
|
|
73
|
-
{ buyer_identity: buyer_identity, store_id: store_id, product_id: product_id })
|
|
82
|
+
{ buyer_identity: buyer_identity, store_id: store_id, product_id: product_id, purpose: purpose })
|
|
74
83
|
end
|
|
75
84
|
|
|
76
85
|
# Anonymous checkout: the buyer fills everything in on the page.
|
|
@@ -92,6 +101,31 @@ module Waffo
|
|
|
92
101
|
)
|
|
93
102
|
end
|
|
94
103
|
|
|
104
|
+
# --- Customer portal ----------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
# Signs a customer who is signed in on your site into the store's customer portal, with no
|
|
107
|
+
# email code. Returns {"portalUrl" => "https://...#token=...", "expiresAt" => ISO 8601}.
|
|
108
|
+
#
|
|
109
|
+
# `buyer_identity` is the one given at authenticated checkout, taken from your own session,
|
|
110
|
+
# never from the request; `store_id` defaults to the configured store. The portal opens on
|
|
111
|
+
# the API key's environment. The URL is a bearer credential: make one per click, answer
|
|
112
|
+
# it with a 302 (Cache-Control: no-store, Referrer-Policy: no-referrer), and never log,
|
|
113
|
+
# cache or send it. Waffo::Pancake::CustomerPortalController does that in Rails.
|
|
114
|
+
def create_customer_portal_link(buyer_identity:, store_id: @store_id)
|
|
115
|
+
raise ArgumentError, "store_id is required (WAFFO_STORE_ID)" if blank?(store_id)
|
|
116
|
+
raise ArgumentError, "buyer_identity is required" if blank?(buyer_identity)
|
|
117
|
+
|
|
118
|
+
path = "/v1/actions/auth/issue-session-token"
|
|
119
|
+
data = issue_session_token(buyer_identity: buyer_identity.to_s, store_id: store_id, purpose: "portal")
|
|
120
|
+
%w[portalUrl token].each do |key|
|
|
121
|
+
next unless blank?(data[key])
|
|
122
|
+
|
|
123
|
+
raise Rejected.new("Waffo #{path}: response has no #{key} for purpose portal", status: 200)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
{ "portalUrl" => "#{data["portalUrl"]}#token=#{data["token"]}", "expiresAt" => data["expiresAt"] }
|
|
127
|
+
end
|
|
128
|
+
|
|
95
129
|
# --- Orders ------------------------------------------------------------------------
|
|
96
130
|
|
|
97
131
|
# pending → canceled at once; active, trialing and past_due → canceling, ending with the
|
|
@@ -109,9 +143,11 @@ module Waffo
|
|
|
109
143
|
action("/v1/actions/onetime-order/cancel-order", { order_id: order_id })
|
|
110
144
|
end
|
|
111
145
|
|
|
112
|
-
# Nil when Waffo knows no such order.
|
|
146
|
+
# Nil when Waffo knows no such order. `productId` is the order's current product, so a plan
|
|
147
|
+
# change to another product can be told apart.
|
|
113
148
|
def subscription_order(order_id)
|
|
114
|
-
graphql(SUBSCRIPTION_ORDER_QUERY, id: order_id)&.dig("subscriptionOrder")
|
|
149
|
+
order = graphql(SUBSCRIPTION_ORDER_QUERY, id: order_id)&.dig("subscriptionOrder")
|
|
150
|
+
order && order.merge("productId" => order.dig("subscriptionProduct", "id"))
|
|
115
151
|
end
|
|
116
152
|
|
|
117
153
|
# --- Products ----------------------------------------------------------------------
|
|
@@ -146,6 +182,29 @@ module Waffo
|
|
|
146
182
|
action("/v1/actions/store/remove-webhook", { id: id })
|
|
147
183
|
end
|
|
148
184
|
|
|
185
|
+
# Delivery records, newest first, for the API key's environment: id, eventType, eventId,
|
|
186
|
+
# payload, webhookUrl, status (pending / success / failed), httpStatus, responseBody,
|
|
187
|
+
# attemptCount, lastAttemptedAt and createdAt.
|
|
188
|
+
#
|
|
189
|
+
# Waffo has no redelivery: a delivery that failed every retry stays failed. To process it
|
|
190
|
+
# again, hand Waffo::Pancake::Webhook::Event.wrap(delivery["payload"]) to your handler.
|
|
191
|
+
#
|
|
192
|
+
# `filter` takes Waffo's GraphQL filter fields, e.g. { eventType: { eq: "refund.succeeded" } };
|
|
193
|
+
# `status` and `event_type` are shortcuts for an `eq` on those fields.
|
|
194
|
+
def webhook_deliveries(store_id: @store_id, status: nil, event_type: nil, filter: nil, limit: 20, offset: 0)
|
|
195
|
+
filter = delivery_filter(status, event_type, filter)
|
|
196
|
+
query = "query ($storeId: String!, $limit: Int, $offset: Int) { webhookDeliveries(storeId: $storeId, " \
|
|
197
|
+
"limit: $limit, offset: $offset#{filter}) { #{WEBHOOK_DELIVERY_FIELDS} } }"
|
|
198
|
+
graphql(query, storeId: require_store_id(store_id), limit: Integer(limit), offset: Integer(offset))
|
|
199
|
+
&.fetch("webhookDeliveries", nil) || []
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def webhook_deliveries_count(store_id: @store_id, status: nil, event_type: nil, filter: nil)
|
|
203
|
+
filter = delivery_filter(status, event_type, filter)
|
|
204
|
+
query = "query ($storeId: String!) { webhookDeliveriesCount(storeId: $storeId#{filter}) }"
|
|
205
|
+
graphql(query, storeId: require_store_id(store_id))&.fetch("webhookDeliveriesCount", nil)
|
|
206
|
+
end
|
|
207
|
+
|
|
149
208
|
# --- Low level ---------------------------------------------------------------------
|
|
150
209
|
|
|
151
210
|
# Returns `data`. Raises when the response carries `errors` and no data.
|
|
@@ -165,7 +224,42 @@ module Waffo
|
|
|
165
224
|
envelope["data"] || {}
|
|
166
225
|
end
|
|
167
226
|
|
|
227
|
+
# A Ruby value as a GraphQL input literal: hashes as input objects, symbols and times as strings.
|
|
228
|
+
def self.graphql_literal(value)
|
|
229
|
+
case value
|
|
230
|
+
when Hash
|
|
231
|
+
fields = value.map do |key, field|
|
|
232
|
+
raise ArgumentError, "invalid GraphQL field name #{key.inspect}" unless key.to_s.match?(/\A[_A-Za-z]\w*\z/)
|
|
233
|
+
|
|
234
|
+
"#{key}: #{graphql_literal(field)}"
|
|
235
|
+
end
|
|
236
|
+
"{ #{fields.join(", ")} }"
|
|
237
|
+
when Array then "[#{value.map { |item| graphql_literal(item) }.join(", ")}]"
|
|
238
|
+
when Symbol then graphql_literal(value.to_s)
|
|
239
|
+
when Time then JSON.generate(value.utc.iso8601(3))
|
|
240
|
+
when String, Integer, Float, true, false, nil then JSON.generate(value)
|
|
241
|
+
else raise ArgumentError, "cannot send #{value.class} in a GraphQL filter"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
168
245
|
private
|
|
246
|
+
def delivery_filter(status, event_type, filter)
|
|
247
|
+
if status && !WEBHOOK_DELIVERY_STATUSES.include?(status.to_s)
|
|
248
|
+
raise ArgumentError, "status must be one of #{WEBHOOK_DELIVERY_STATUSES.join(", ")}"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
filter = (filter || {}).to_h.transform_keys(&:to_s)
|
|
252
|
+
filter["status"] = { "eq" => status.to_s } if status
|
|
253
|
+
filter["eventType"] = { "eq" => event_type.to_s } if event_type
|
|
254
|
+
filter.empty? ? "" : ", filter: #{self.class.graphql_literal(filter)}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def require_store_id(store_id)
|
|
258
|
+
raise ArgumentError, "store_id is required (WAFFO_STORE_ID)" if blank?(store_id)
|
|
259
|
+
|
|
260
|
+
store_id
|
|
261
|
+
end
|
|
262
|
+
|
|
169
263
|
def request(path, params)
|
|
170
264
|
json = JSON.generate(params)
|
|
171
265
|
timestamp = Time.now.to_i.to_s
|
|
@@ -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,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,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
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -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,9 @@ 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/deprecation"
|
|
7
8
|
require_relative "pancake/webhook"
|
|
9
|
+
require_relative "pancake/webhook/event"
|
|
8
10
|
require_relative "pancake/configuration"
|
|
9
11
|
require_relative "pancake/railtie" if defined?(Rails::Railtie)
|
|
10
12
|
|
|
@@ -13,6 +15,7 @@ module Waffo
|
|
|
13
15
|
module Pancake
|
|
14
16
|
# Need ActiveSupport / Action Pack; loaded on first use.
|
|
15
17
|
autoload :WebhookController, "waffo/pancake/webhook_controller"
|
|
18
|
+
autoload :CustomerPortalController, "waffo/pancake/customer_portal_controller"
|
|
16
19
|
autoload :TestHelpers, "waffo/pancake/test_helpers"
|
|
17
20
|
end
|
|
18
21
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Good First Issue
|
|
@@ -43,12 +43,15 @@ files:
|
|
|
43
43
|
- lib/waffo/pancake.rb
|
|
44
44
|
- lib/waffo/pancake/client.rb
|
|
45
45
|
- lib/waffo/pancake/configuration.rb
|
|
46
|
+
- lib/waffo/pancake/customer_portal_controller.rb
|
|
47
|
+
- lib/waffo/pancake/deprecation.rb
|
|
46
48
|
- lib/waffo/pancake/errors.rb
|
|
47
49
|
- lib/waffo/pancake/keys.rb
|
|
48
50
|
- lib/waffo/pancake/railtie.rb
|
|
49
51
|
- lib/waffo/pancake/test_helpers.rb
|
|
50
52
|
- lib/waffo/pancake/version.rb
|
|
51
53
|
- lib/waffo/pancake/webhook.rb
|
|
54
|
+
- lib/waffo/pancake/webhook/event.rb
|
|
52
55
|
- lib/waffo/pancake/webhook_controller.rb
|
|
53
56
|
homepage: https://github.com/goodfirstissueorg/waffo-pancake-ruby
|
|
54
57
|
licenses:
|