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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b498f6ef1ac1fbdec66122b1a9d4758ebd6ea917840563c8f296c9e020c03bdd
4
- data.tar.gz: c34ede893f8f1a84c2d6f7e71968dddfed09ce1a20e43140156805cf2bf93e39
3
+ metadata.gz: 53bc96e6da7f2a84f2ad08a962fe1bb1f7041d630a74446f933c78a475384dfa
4
+ data.tar.gz: 8ff4d5eabce81147a7bdeafe43d744cb82782ef047116d5d62a38208b2857d87
5
5
  SHA512:
6
- metadata.gz: bee472c21659562c0a68275d615eb5af3f21d95ec1937f2aed7fab7a5570b1f968e6b27f3ff4bbd7fa82598eef3e30407b35afbf8503759cca44490aa9e3bb85
7
- data.tar.gz: f96b92d6942e7a94e298153a02c1f9a5b480afb1c399ba420da5044964bbe7f946ca6d02f09c54086d256f2881485ff250c166536223d7493122fc01b8577926
6
+ metadata.gz: a567f41bee5bbedd913786a18623285f45303f2b7f45e13c0a60171bead24ab70b65e51f9231043f857128e30e5229688fa553a07a3799b768fc89b799654881
7
+ data.tar.gz: 53b910c7287fe30d1c2ef9f0796f9e4de5e868a058db982f3e421efb0d2494b03683ed3202d814e5431a1f8902493a3a09d1023d280665cd8fc38b4b18df53d3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Every endpoint in Waffo's API reference now has a method:
6
+ - Stores: `create_store`, `update_store`, `delete_store`.
7
+ - Store members: `add_store_merchant`, `remove_store_merchant`, `update_store_merchant_role`.
8
+ - Webhooks: `update_webhook`.
9
+ - Notification recipients: `add_notification_recipient`, `update_notification_recipient`,
10
+ `remove_notification_recipient`.
11
+ - Products: `update_onetime_product`, `publish_onetime_product`,
12
+ `update_onetime_product_status`, `update_subscription_product_status`.
13
+ - Product groups: `create_subscription_product_group`, `update_…`, `delete_…`, `publish_…`.
14
+ - Promotions: `create_coupon`, `update_coupon`, `deactivate_coupon`, `create_promotion_code`,
15
+ `generate_promotion_codes`, `deactivate_promotion_code`.
16
+ - Refund tickets: `create_refund_ticket`, `resubmit_refund_ticket`.
17
+ - Plan changes: `create_plan_change_session`, `create_authenticated_plan_change`.
18
+ - Content safety: `scan_prompt`.
19
+ - `Client#customer(token, environment:)` returns a `CustomerSession`, authenticated with a session
20
+ token, with the customer actions plus `create_onetime_order` and `create_subscription_order`.
21
+ - `StoreCheckout.new(store_slug:, environment:)` for anonymous checkout with only the store slug.
22
+ - GraphQL readers, generated from the schema that introspection reports: lists, `*_count` and
23
+ single-record methods, `*_aggregate` methods and analytics methods. Filters travel as typed
24
+ variables.
25
+ - `idempotency_key:` on every action, sent as `X-Idempotency-Key`.
26
+ - `Waffo::Pancake::NULL` sends an explicit JSON null, which clears a field. A nil is still dropped.
27
+ - `Error#retry_after`, from the `Retry-After` header.
28
+ - An action's result has a `"warnings"` key when Waffo sent warnings.
29
+ - `Client.new(environment:)`, defaulting to the configured environment.
30
+ - `TestHelpers::FakeClient` has every API method, generated, so a new endpoint cannot be missing.
31
+ - Changed: `graphql` now raises on a non-2xx status without `errors`. It used to return nil.
32
+ - Known: with an API key, `reactivate_subscription` and `cancel_onetime_order` get a 401 from
33
+ Waffo's gateway. Call them through `client.customer(token)`.
34
+ - `rake test:live` runs the real API with a test-mode key. It is not part of `rake test`.
35
+
36
+ ## 0.3.0
37
+
38
+ - Webhook amount fields (Waffo, 2026-09-20 to 2026-09-22):
39
+ - `Webhook.verify` and `Waffo::Pancake.verify_webhook` return a `Webhook::Event`, a Hash
40
+ subclass, so code that reads it as a Hash keeps working. `Webhook::Event.wrap` makes one from
41
+ a stored payload (Hash or JSON).
42
+ - `event.data` readers for the new fields: `charged_amount`, `refunded_amount`,
43
+ `original_charged_amount`, `list_price`, `original_payment`, `plan_price`, `price`,
44
+ `period_number`, `first_period?`, `renewal?`.
45
+ - Reading a deprecated field (`amount`, `total`, `subtotal`, `taxAmount`, `taxRate`, `taxName`)
46
+ with `[]`, `fetch` or `dig` reports a deprecation warning naming its replacement for that
47
+ event type. `config.deprecator` chooses where it goes: a once-per-message `Kernel#warn` by
48
+ default, `Rails.application.deprecators[:waffo_pancake]` in Rails, nil for none.
49
+ - `Webhook::DEPRECATED_FIELDS`, `PAYMENT_EVENT_TYPES`, `REFUND_EVENT_TYPES` and
50
+ `Webhook.family`.
51
+ - Customer portal sign-in without an email code: `Client#create_customer_portal_link`
52
+ (`issue-session-token` with `purpose: "portal"`), `issue_session_token(purpose:)`, and the
53
+ `Waffo::Pancake::CustomerPortalController` Rails concern, which answers with a 302 and
54
+ `Cache-Control: no-store` / `Referrer-Policy: no-referrer`. The Railtie adds `#token=` to
55
+ `filter_redirect`, so these URLs stay out of the request log.
56
+ - Webhook delivery records: `Client#webhook_deliveries` and `#webhook_deliveries_count`, with
57
+ `status:`, `event_type:`, `filter:`, `limit:` and `offset:`. Waffo has no redelivery API;
58
+ run your handler on `Webhook::Event.wrap(delivery["payload"])` instead.
59
+ - `Client.new(store_id:)`, defaulting to the configured store, for the calls above.
60
+ - `TestHelpers::FakeClient#create_customer_portal_link` and `#webhook_deliveries`.
61
+ - The generated `WaffoWebhookEvent#data` and `#waffo_event` wrap the stored payload; the
62
+ generated job reads the new amount fields.
63
+
3
64
  ## 0.2.1
4
65
 
5
66
  - `Client#subscription_order` also returns the order's current product as `productId`, read
data/README.md CHANGED
@@ -68,17 +68,47 @@ 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
74
102
  client.cancel_subscription("ORD_...") # => {"orderId" => "ORD_...", "status" => "canceling"}
75
- client.reactivate_subscription("ORD_...") # while still canceling
76
103
  client.subscription_order("ORD_...") # GraphQL; nil when unknown
77
104
  # => {"status" => "active", "currentPeriodEnd" => "2027-10-08T...", "merchantProvidedBuyerIdentity" => "user-42", ...}
78
105
  ```
79
106
 
80
107
  `canceling` keeps access until `currentPeriodEnd`. The terminal `canceled` arrives as a webhook.
81
108
 
109
+ Waffo's gateway refuses an API key on `reactivate_subscription` and `cancel_onetime_order` with
110
+ a 401. Call them as the customer; see [Customer sessions](#customer-sessions).
111
+
82
112
  ## Products
83
113
 
84
114
  ```ruby
@@ -91,11 +121,102 @@ client.publish_subscription_product(id: "PROD_...") # test → prod, first publ
91
121
 
92
122
  Amounts are display strings (`"9.99"`), not cents.
93
123
 
94
- ## GraphQL and other endpoints
124
+ ## Stores, coupons and refunds
125
+
126
+ ```ruby
127
+ client.create_store(name: "My Store")
128
+ client.update_store(id: "STO_...", logo: Waffo::Pancake::NULL) # NULL clears a field; nil is dropped
129
+
130
+ client.update_onetime_product_status(id: "PROD_...", status: "inactive")
131
+ client.create_subscription_product_group(store_id: "STO_...", name: "Plans", product_ids: ["PROD_..."])
132
+
133
+ client.update_webhook(id: webhook["id"], url: "https://example.com/hooks/waffo")
134
+ client.add_notification_recipient(store_id: "STO_...", email: "ops@example.com")
135
+ client.add_store_merchant(store_id: "STO_...", email: "ada@example.com", role: "member")
136
+
137
+ coupon = client.create_coupon(store_id: "STO_...", name: "Launch", discount: { type: "percentage", percentOff: 20 })
138
+ client.generate_promotion_codes(coupon_id: "CPN_...", count: 100, prefix: "LAUNCH")
139
+
140
+ client.create_refund_ticket(payment_id: "PAY_...", reason: "Duplicate charge",
141
+ requested_amount: { amount: "9.99", currency: "USD" })
142
+ client.scan_prompt(prompt: "...")
143
+ ```
144
+
145
+ Every action takes `idempotency_key:` (1 to 256 characters of `A-Z a-z 0-9 _ -`). It is sent as
146
+ `X-Idempotency-Key`, and Waffo keeps the answer for 24 hours. Nothing is sent unless you pass one:
147
+
148
+ ```ruby
149
+ client.create_refund_ticket(..., idempotency_key: "refund-#{order.id}")
150
+ ```
151
+
152
+ When Waffo sends warnings with a successful answer, the result has a `"warnings"` key.
153
+
154
+ ## Customer sessions
155
+
156
+ Some routes act as one customer and take a session token instead of an API key. A token does not
157
+ carry an environment, so the session needs one, from `environment:` or the configuration.
158
+
159
+ ```ruby
160
+ token = client.issue_session_token(buyer_identity: "user-42", store_id: "STO_...")["token"]
161
+ customer = client.customer(token, environment: "test")
162
+
163
+ customer.reactivate_subscription("ORD_...")
164
+ customer.cancel_onetime_order("ORD_...")
165
+ customer.create_onetime_order(checkout_session_id: "cs_...", billing_detail: { country: "US", ... },
166
+ language: "en", buyer_email: "ada@example.com")
167
+ # create_subscription_order takes the same arguments. buyer_email: is required unless the checkout
168
+ # session already has one.
169
+ customer.graphql("{ onetimeOrders { id status } }")
170
+ ```
171
+
172
+ `inspect` hides the token. A plan change the customer confirms on Waffo's page:
173
+
174
+ ```ruby
175
+ link = client.create_authenticated_plan_change(origin_order_id: "ORD_...", product_id: "PROD_...",
176
+ currency: "USD", buyer_identity: "user-42")
177
+ redirect_to link["checkoutUrl"], allow_other_host: true # a bearer credential: never log it
178
+ ```
179
+
180
+ Anonymous checkout with only the public store slug, which needs no secret:
95
181
 
96
182
  ```ruby
97
- client.graphql("query ($id: String!) { onetimeOrder(id: $id) { id status } }", id: "ORD_...")
98
- client.action("/v1/actions/refund-ticket/create-ticket", { payment_id: "PAY_...", reason: "..." })
183
+ storefront = Waffo::Pancake::StoreCheckout.new(store_slug: "my-store", environment: "test")
184
+ storefront.create_checkout_session(product_id: "PROD_...", currency: "USD")
185
+ ```
186
+
187
+ ## Reading data
188
+
189
+ ```ruby
190
+ client.onetime_products(limit: 20) # store_id: defaults to WAFFO_STORE_ID
191
+ client.onetime_products(store_slug: "my-store")
192
+ client.onetime_products_count
193
+ client.onetime_product("PROD_...") # nil when unknown
194
+ client.payments(filter: { status: { eq: "succeeded" } }, order_by: :created_at_desc, offset: 20)
195
+ client.promotion_codes(coupon_id: "CPN_...")
196
+ client.subscription_product_versions("PROD_...")
197
+ client.me
198
+ ```
199
+
200
+ There are readers for stores, merchants, store merchants, products, product groups, orders,
201
+ payments, refunds, refund tickets, chargebacks, customers, coupons, promotion codes, discount
202
+ applications, email deliveries, cancel reasons, webhooks and notification recipients.
203
+ `Waffo::Pancake::API::Queries::LISTS` is the full table. `fields:` replaces the default selection.
204
+ It is written into the query, so it must come from your code and never from a request. Filters
205
+ travel as GraphQL variables, so they are safe for any value.
206
+
207
+ Analytics return the fields you ask for, and aggregates can group by a dimension:
208
+
209
+ ```ruby
210
+ client.order_statistics(fields: "totalCount buyerMetrics { totalBuyers }",
211
+ start_date: 1.month.ago, end_date: Time.now, currency: "USD")
212
+ client.payments_aggregate(group_by: :status, granularity: :month, filter: { currency: { eq: "USD" } })
213
+ ```
214
+
215
+ Anything else goes through the low-level calls:
216
+
217
+ ```ruby
218
+ client.graphql("query ($id: String!) { payoutTicket(id: $id) { id status } }", id: "...")
219
+ client.action("/v1/actions/some-group/some-action", { some_param: "..." })
99
220
  ```
100
221
 
101
222
  ## Webhooks
@@ -112,9 +233,10 @@ class WaffoWebhooksController < ActionController::Base
112
233
  request.headers["X-Waffo-Signature"],
113
234
  environment: "prod" # or "test"; omit to try prod, then test
114
235
  )
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
236
+ # Deduplicate on event.id: a retry redelivers the same id.
237
+ case event.event_type
238
+ when "order.completed" then # record event.data.charged_amount
239
+ when "subscription.activated", "subscription.renewed" then # grant access until data["currentPeriodEnd"]
118
240
  when "subscription.canceled" then # revoke access
119
241
  end
120
242
  head :ok
@@ -131,12 +253,68 @@ both), or pass `public_key:`. Timestamps may be up to 45 minutes old and 1 minut
131
253
 
132
254
  `Waffo::Pancake::Webhook::EVENT_TYPES` lists the event types.
133
255
 
256
+ ### Amount fields
257
+
258
+ `verify` returns a `Waffo::Pancake::Webhook::Event`, a Hash with readers. Its `data` reads
259
+ the amount fields by what they describe:
260
+
261
+ | Reader | Field | Events |
262
+ |---|---|---|
263
+ | `charged_amount` | `chargedAmount` | Payment (`order.completed`, `subscription.payment_succeeded`): what the buyer was actually charged, after prorated credits, trials and zero-amount card checks |
264
+ | `list_price` | `listPrice` | Payment: the list price behind the charge (`total`, `subtotal`, `taxAmount`, `taxRate`, `taxName`) |
265
+ | `refunded_amount` | `refundedAmount` | `refund.*`: what this refund returned |
266
+ | `original_charged_amount` | `originalChargedAmount` | `refund.*`: what the refunded payment actually collected, the most a refund can reach |
267
+ | `original_payment` | `originalPayment` | `refund.*`: the refunded payment's list price |
268
+ | `plan_price` | `planPrice` | Other `subscription.*` events: the plan's price for the current period |
269
+ | `period_number`, `first_period?`, `renewal?` | `periodNumber` | Subscription events, `subscription.payment_succeeded` and subscription refunds: 1 for the first period, N for the Nth renewal |
270
+
271
+ `chargedAmount`, `refundedAmount` and `originalChargedAmount` are absent (nil) when the payment
272
+ channel reported no amount. `periodNumber` counts failed charges too, is `0` before a scheduled
273
+ plan change takes effect, and is not a deduplication key.
274
+
275
+ `amount`, `total`, `subtotal`, `taxAmount`, `taxRate` and `taxName` are deprecated: one name meant
276
+ a different amount per event family. Waffo sends them until its next major API version, not
277
+ before 2027-09-20. Since 2026-09-21, `amount` on payment events is the charged amount, so a check
278
+ such as `amount == total` fails there. Reading one of them with `[]`, `fetch` or `dig` still
279
+ returns the value, and reports the field to read instead for that event type:
280
+
281
+ ```
282
+ DEPRECATION WARNING: Waffo webhook data["amount"] is deprecated on refund.succeeded; read data["refundedAmount"] instead. ...
283
+ ```
284
+
285
+ Outside Rails each message is printed once per process with `Kernel#warn`. In Rails it goes to
286
+ `Rails.application.deprecators[:waffo_pancake]`, so `config.active_support.deprecation` applies
287
+ (`:raise` in tests finds every place to change). Set `config.deprecator` to any object answering
288
+ `warn(message, callstack)`, or to nil to turn the warnings off. A stored payload (or one that went
289
+ through a job) is a plain Hash again: `Waffo::Pancake::Webhook::Event.wrap(payload)` brings the
290
+ readers and warnings back.
291
+
292
+ ### Delivery records
293
+
294
+ ```ruby
295
+ client.webhook_deliveries(status: :failed, limit: 20) # store_id: defaults to WAFFO_STORE_ID
296
+ # => [{"id" => ..., "eventType" => ..., "status" => "failed", "httpStatus" => 500, "attemptCount" => 4,
297
+ # "payload" => ..., "responseBody" => ..., "lastAttemptedAt" => ..., ...}]
298
+ client.webhook_deliveries_count(status: :failed)
299
+ client.webhook_deliveries(event_type: "refund.succeeded", filter: { createdAt: { gte: 1.week.ago } }, offset: 20)
300
+ ```
301
+
302
+ Waffo retries a delivery 3 times and has no manual redelivery. To process a failed one again,
303
+ run your own handler on its payload:
304
+
305
+ ```ruby
306
+ client.webhook_deliveries(status: :failed).each do |delivery|
307
+ HandleWaffoEventJob.perform_later(Waffo::Pancake::Webhook::Event.wrap(delivery["payload"]))
308
+ end
309
+ ```
310
+
134
311
  ## Rails
135
312
 
136
313
  Inside a Rails app the gem configures itself. Settings are read in this order, the last one
137
314
  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`.
315
+ `Waffo::Pancake.configure` in an initializer. The logger is `Rails.logger`, `private_key`
316
+ is added to `filter_parameters`, and redirects to a URL with `#token=` (authenticated checkout,
317
+ customer portal) to `filter_redirect`.
140
318
 
141
319
  ```yaml
142
320
  # bin/rails credentials:edit
@@ -165,7 +343,7 @@ class WaffoWebhooksController < ActionController::Base
165
343
  include Waffo::Pancake::WebhookController
166
344
 
167
345
  def create
168
- HandleWaffoEventJob.perform_later(waffo_event) # verified, parsed delivery
346
+ HandleWaffoEventJob.perform_later(waffo_event) # verified Webhook::Event; wrap it again in the job
169
347
  head :ok
170
348
  end
171
349
  end
@@ -217,14 +395,15 @@ end
217
395
 
218
396
  `FakeClient` answers `subscription_order` from the orders it was given, moves an order to
219
397
  `canceling` / `active` on cancel / reactivate, records every call in `calls`, and raises
220
- `fail_with` when set.
398
+ `fail_with` when set. It has every API method: lists answer `[]`, counts `0`, reads `nil` and
399
+ writes `{}`. `customer(token)` answers with the same fake.
221
400
 
222
401
  ## Errors
223
402
 
224
403
  | Class | When |
225
404
  |---|---|
226
405
  | `Waffo::Pancake::Rejected` | Waffo refused the request (4xx other than 429). `status` and `errors` carry the details. |
227
- | `Waffo::Pancake::Unavailable` | Network failure, timeout, 429 or 5xx. Worth retrying later. |
406
+ | `Waffo::Pancake::Unavailable` | Network failure, timeout, 429 or 5xx. Worth retrying later, after `retry_after` seconds when it is set. |
228
407
  | `Waffo::Pancake::ConfigurationError` | Missing merchant id, or a key that does not parse. |
229
408
  | `Waffo::Pancake::InvalidSignature` | A webhook that does not verify. |
230
409
 
@@ -233,7 +412,7 @@ All inherit from `Waffo::Pancake::Error`.
233
412
  ## Testing your code
234
413
 
235
414
  `Client.new` takes a `transport:` callable that replaces the HTTP call. It receives the URI,
236
- the headers and the JSON body and returns `[status, body]`.
415
+ the headers and the JSON body and returns `[status, body]` or `[status, body, headers]`.
237
416
 
238
417
  ```ruby
239
418
  transport = ->(uri, headers, json) { [200, '{"data":{"orderId":"ORD_1","status":"canceling"}}'] }
@@ -249,6 +428,17 @@ bundle install
249
428
  bundle exec rake test # test:core (no Rails loaded) and test:rails
250
429
  ```
251
430
 
431
+ `rake test:live` calls the real API. It needs a **test-mode** key and refuses to run otherwise:
432
+
433
+ ```sh
434
+ WAFFO_LIVE=1 WAFFO_ENVIRONMENT=test WAFFO_MERCHANT_ID=... WAFFO_PRIVATE_KEY=... WAFFO_STORE_ID=... \
435
+ bundle exec rake test:live
436
+ ```
437
+
438
+ `WAFFO_STORE_ID` is only read. Writes go to a temporary store that the run creates and deletes.
439
+ The `Live API` workflow runs this on pushes to `main` and by hand, from the `WAFFO_*` Actions
440
+ secrets. It never runs for pull requests, so a fork cannot reach the key.
441
+
252
442
  Releases are published to RubyGems by the `Release` workflow when a `v*` tag is pushed (see
253
443
  [RELEASING.md](RELEASING.md)). It uses
254
444
  [trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no API key is stored.
@@ -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)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waffo
4
+ module Pancake
5
+ module API
6
+ # Actions that act for one customer, so both Client and CustomerSession have them.
7
+ #
8
+ # The gateway decides which credential a route takes, and the two groups below differ:
9
+ # with an API key, reactivate-order and onetime-order/cancel-order answer 401 "API Key
10
+ # authentication not supported for this route" (tested on 2026-11-04). They stay here so
11
+ # the 0.x Client API keeps its shape, and because Waffo can open these routes later.
12
+ module CustomerActions
13
+ def create_checkout_session(product_id:, currency:, **params)
14
+ action("/v1/actions/checkout/create-session", product_id: product_id, currency: currency, **params)
15
+ end
16
+
17
+ # A checkout session that changes the plan of `origin_order_id`. Without a coupon of its
18
+ # own, the session follows the rules of the origin order's product group.
19
+ def create_plan_change_session(origin_order_id:, product_id:, currency:, **params)
20
+ action("/v1/actions/checkout/create-session", origin_order_id: origin_order_id, product_id: product_id,
21
+ currency: currency, **params)
22
+ end
23
+
24
+ # pending → canceled at once; active, trialing and past_due → canceling, ending with the
25
+ # paid period. The terminal `canceled` arrives later as a webhook. Optional: cancel_reason,
26
+ # cancel_comment.
27
+ def cancel_subscription(order_id, **params)
28
+ action("/v1/actions/subscription-order/cancel-order", order_id: order_id, **params)
29
+ end
30
+
31
+ # Undoes a cancellation while the order is still `canceling`. Session token only; see the
32
+ # module comment.
33
+ def reactivate_subscription(order_id, **params)
34
+ action("/v1/actions/subscription-order/reactivate-order", order_id: order_id, **params)
35
+ end
36
+
37
+ # Session token only; see the module comment.
38
+ def cancel_onetime_order(order_id, **params)
39
+ action("/v1/actions/onetime-order/cancel-order", order_id: order_id, **params)
40
+ end
41
+
42
+ # requested_amount: { amount: "9.99", currency: "USD" }
43
+ def create_refund_ticket(payment_id:, reason:, requested_amount:, **params)
44
+ action("/v1/actions/refund-ticket/create-ticket", payment_id: payment_id, reason: reason,
45
+ requested_amount: requested_amount, **params)
46
+ end
47
+
48
+ def resubmit_refund_ticket(ticket_id:, reason:, requested_amount:, **params)
49
+ action("/v1/actions/refund-ticket/resubmit-ticket", ticket_id: ticket_id, reason: reason,
50
+ requested_amount: requested_amount, **params)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waffo
4
+ module Pancake
5
+ module API
6
+ # Products and subscription product groups. Merchant API key.
7
+ module ProductActions
8
+ # prices: { "USD" => { amount: "9.99", taxCategory: "saas" } }
9
+ def create_subscription_product(store_id:, name:, billing_period:, prices:, **params)
10
+ action("/v1/actions/subscription-product/create-product",
11
+ store_id: store_id, name: name, billing_period: billing_period, prices: prices, **params)
12
+ end
13
+
14
+ def update_subscription_product(id:, **params)
15
+ action("/v1/actions/subscription-product/update-product", id: id, **params)
16
+ end
17
+
18
+ # Copies the test version to production (first publish only).
19
+ def publish_subscription_product(id:, **params)
20
+ action("/v1/actions/subscription-product/publish-product", id: id, **params)
21
+ end
22
+
23
+ # status: "active" or "inactive"
24
+ def update_subscription_product_status(id:, status:, **params)
25
+ action("/v1/actions/subscription-product/update-status", id: id, status: status, **params)
26
+ end
27
+
28
+ def create_onetime_product(store_id:, name:, prices:, **params)
29
+ action("/v1/actions/onetime-product/create-product", store_id: store_id, name: name, prices: prices, **params)
30
+ end
31
+
32
+ def update_onetime_product(id:, **params)
33
+ action("/v1/actions/onetime-product/update-product", id: id, **params)
34
+ end
35
+
36
+ # Copies the test version to production.
37
+ def publish_onetime_product(id:, **params)
38
+ action("/v1/actions/onetime-product/publish-product", id: id, **params)
39
+ end
40
+
41
+ # status: "active" or "inactive"
42
+ def update_onetime_product_status(id:, status:, **params)
43
+ action("/v1/actions/onetime-product/update-status", id: id, status: status, **params)
44
+ end
45
+
46
+ def create_subscription_product_group(store_id:, name:, **params)
47
+ action("/v1/actions/subscription-product-group/create-group", store_id: store_id, name: name, **params)
48
+ end
49
+
50
+ def update_subscription_product_group(id:, **params)
51
+ action("/v1/actions/subscription-product-group/update-group", id: id, **params)
52
+ end
53
+
54
+ def delete_subscription_product_group(id:, **params)
55
+ action("/v1/actions/subscription-product-group/delete-group", id: id, **params)
56
+ end
57
+
58
+ def publish_subscription_product_group(id:, **params)
59
+ action("/v1/actions/subscription-product-group/publish-group", id: id, **params)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waffo
4
+ module Pancake
5
+ module API
6
+ # Coupons, promotion codes and the prompt scan. Merchant API key.
7
+ module PromotionActions
8
+ # discount: { type: "percentage", percentOff: 20 }
9
+ def create_coupon(store_id:, name:, discount:, **params)
10
+ action("/v1/actions/coupon/create-coupon", store_id: store_id, name: name, discount: discount, **params)
11
+ end
12
+
13
+ def update_coupon(id:, name:, **params)
14
+ action("/v1/actions/coupon/update-coupon", id: id, name: name, **params)
15
+ end
16
+
17
+ def deactivate_coupon(id:, **params)
18
+ action("/v1/actions/coupon/deactivate-coupon", id: id, **params)
19
+ end
20
+
21
+ def create_promotion_code(coupon_id:, **params)
22
+ action("/v1/actions/promotion-code/create-promotion-code", coupon_id: coupon_id, **params)
23
+ end
24
+
25
+ def generate_promotion_codes(coupon_id:, count:, **params)
26
+ action("/v1/actions/promotion-code/generate-promotion-codes", coupon_id: coupon_id, count: count, **params)
27
+ end
28
+
29
+ def deactivate_promotion_code(id:, **params)
30
+ action("/v1/actions/promotion-code/deactivate-promotion-code", id: id, **params)
31
+ end
32
+
33
+ def scan_prompt(prompt:, **params)
34
+ action("/v1/actions/verification/scan-prompt", prompt: prompt, **params)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end