waffo-pancake 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/README.md +203 -13
- data/lib/generators/waffo_pancake/install/templates/webhook_event.rb.tt +5 -1
- data/lib/generators/waffo_pancake/install/templates/webhook_job.rb.tt +7 -1
- data/lib/waffo/pancake/api/customer_actions.rb +55 -0
- data/lib/waffo/pancake/api/product_actions.rb +64 -0
- data/lib/waffo/pancake/api/promotion_actions.rb +39 -0
- data/lib/waffo/pancake/api/queries.rb +292 -0
- data/lib/waffo/pancake/api/store_actions.rb +64 -0
- data/lib/waffo/pancake/client.rb +140 -140
- data/lib/waffo/pancake/configuration.rb +9 -0
- data/lib/waffo/pancake/connection.rb +113 -0
- data/lib/waffo/pancake/customer_portal_controller.rb +43 -0
- data/lib/waffo/pancake/customer_session.rb +55 -0
- data/lib/waffo/pancake/deprecation.rb +40 -0
- data/lib/waffo/pancake/errors.rb +5 -3
- data/lib/waffo/pancake/railtie.rb +16 -1
- data/lib/waffo/pancake/requests.rb +91 -0
- data/lib/waffo/pancake/store_checkout.rb +41 -0
- data/lib/waffo/pancake/test_helpers.rb +48 -11
- data/lib/waffo/pancake/version.rb +1 -1
- data/lib/waffo/pancake/webhook/event.rb +156 -0
- data/lib/waffo/pancake/webhook.rb +6 -4
- data/lib/waffo/pancake.rb +4 -0
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 53bc96e6da7f2a84f2ad08a962fe1bb1f7041d630a74446f933c78a475384dfa
|
|
4
|
+
data.tar.gz: 8ff4d5eabce81147a7bdeafe43d744cb82782ef047116d5d62a38208b2857d87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
##
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
116
|
-
case event
|
|
117
|
-
when "
|
|
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`,
|
|
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
|
|
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
|
-
|
|
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)
|
|
@@ -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
|