waffo-pancake 0.2.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b498f6ef1ac1fbdec66122b1a9d4758ebd6ea917840563c8f296c9e020c03bdd
4
- data.tar.gz: c34ede893f8f1a84c2d6f7e71968dddfed09ce1a20e43140156805cf2bf93e39
3
+ metadata.gz: 87fe6008dd468bd2fe340702f5b8a6b5f4ce9141a7b49b9c069cf37062aaca3f
4
+ data.tar.gz: f9d37be2f832780519fdcd3319eb1ac3a96e073c588c153bea72477c4518cd61
5
5
  SHA512:
6
- metadata.gz: bee472c21659562c0a68275d615eb5af3f21d95ec1937f2aed7fab7a5570b1f968e6b27f3ff4bbd7fa82598eef3e30407b35afbf8503759cca44490aa9e3bb85
7
- data.tar.gz: f96b92d6942e7a94e298153a02c1f9a5b480afb1c399ba420da5044964bbe7f946ca6d02f09c54086d256f2881485ff250c166536223d7493122fc01b8577926
6
+ metadata.gz: 77f17baee5d56461088b0b3ecc8b948d2d341d35b373ae6845593e5504632a17f995fbcc63d889821341267305edc70c1bbb21088ce6870927ca4a154043d72c
7
+ data.tar.gz: 9e0cb6aae549bc3fd02978fd432252d7d5eefe4018505360284374548158fd62b5c5ea10d59f3c8769a3748a08eca053d3e75ed8c25e5a615bfc739a6d15c635
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
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
+
3
31
  ## 0.2.1
4
32
 
5
33
  - `Client#subscription_order` also returns the order's current product as `productId`, read
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["id"]: a retry redelivers the same id.
116
- case event["eventType"]
117
- when "subscription.activated", "subscription.renewed" then # grant access until data.currentPeriodEnd
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`, and `private_key`
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, parsed delivery
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
- def data = payload.fetch("data", {})
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
- # Compare event.data["refundedAmount"] with event.data["originalChargedAmount"].
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)
@@ -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
@@ -35,7 +36,11 @@ module Waffo
35
36
  }
36
37
  GRAPHQL
37
38
 
38
- attr_reader :merchant_id, :base_url
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
39
44
 
40
45
  # The canonical request the gateway checks `X-Signature` against: method, path,
41
46
  # timestamp (seconds) and the base64 SHA-256 of the body, one per line.
@@ -51,12 +56,13 @@ module Waffo
51
56
  # no answer). `logger` gets one info line per request and `instrumenter` one
52
57
  # `request.waffo_pancake` event; neither ever sees the body or the key.
53
58
  def initialize(config: Pancake.configuration, merchant_id: config.merchant_id, private_key: config.private_key,
54
- base_url: config.base_url, timeout: config.timeout, logger: config.logger,
59
+ store_id: config.store_id, base_url: config.base_url, timeout: config.timeout, logger: config.logger,
55
60
  instrumenter: config.instrumenter, transport: nil)
56
61
  raise ConfigurationError, "Missing merchant_id (WAFFO_MERCHANT_ID)" if blank?(merchant_id)
57
62
  raise ConfigurationError, "Missing private_key (WAFFO_PRIVATE_KEY)" if blank?(private_key)
58
63
 
59
64
  @merchant_id = merchant_id
65
+ @store_id = blank?(store_id) ? nil : store_id
60
66
  @private_key = Keys.private_key(private_key)
61
67
  @base_url = base_url.to_s.chomp("/")
62
68
  @timeout = timeout
@@ -67,11 +73,13 @@ module Waffo
67
73
 
68
74
  # --- Checkout ----------------------------------------------------------------------
69
75
 
70
- def issue_session_token(buyer_identity:, store_id: nil, product_id: nil)
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)
71
79
  raise ArgumentError, "store_id or product_id is required" if store_id.nil? && product_id.nil?
72
80
 
73
81
  action("/v1/actions/auth/issue-session-token",
74
- { 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 })
75
83
  end
76
84
 
77
85
  # Anonymous checkout: the buyer fills everything in on the page.
@@ -93,6 +101,31 @@ module Waffo
93
101
  )
94
102
  end
95
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
+
96
129
  # --- Orders ------------------------------------------------------------------------
97
130
 
98
131
  # pending → canceled at once; active, trialing and past_due → canceling, ending with the
@@ -149,6 +182,29 @@ module Waffo
149
182
  action("/v1/actions/store/remove-webhook", { id: id })
150
183
  end
151
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
+
152
208
  # --- Low level ---------------------------------------------------------------------
153
209
 
154
210
  # Returns `data`. Raises when the response carries `errors` and no data.
@@ -168,7 +224,42 @@ module Waffo
168
224
  envelope["data"] || {}
169
225
  end
170
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
+
171
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
+
172
263
  def request(path, params)
173
264
  json = JSON.generate(params)
174
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, and `private_key` is filtered from logs.
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. `mode` and `store_id` default to the configuration.
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`, moves
74
- # an order's status on cancel / reactivate the way Waffo does, and records every call
75
- # in `calls` as [method, arguments]. Set `fail_with` to an error to make calls raise it.
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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Waffo
4
4
  module Pancake
5
- VERSION = "0.2.1"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -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"] # => "subscription.activated"
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.25.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.2.1
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: