waffo-pancake 0.3.0 → 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: 87fe6008dd468bd2fe340702f5b8a6b5f4ce9141a7b49b9c069cf37062aaca3f
4
- data.tar.gz: f9d37be2f832780519fdcd3319eb1ac3a96e073c588c153bea72477c4518cd61
3
+ metadata.gz: 53bc96e6da7f2a84f2ad08a962fe1bb1f7041d630a74446f933c78a475384dfa
4
+ data.tar.gz: 8ff4d5eabce81147a7bdeafe43d744cb82782ef047116d5d62a38208b2857d87
5
5
  SHA512:
6
- metadata.gz: 77f17baee5d56461088b0b3ecc8b948d2d341d35b373ae6845593e5504632a17f995fbcc63d889821341267305edc70c1bbb21088ce6870927ca4a154043d72c
7
- data.tar.gz: 9e0cb6aae549bc3fd02978fd432252d7d5eefe4018505360284374548158fd62b5c5ea10d59f3c8769a3748a08eca053d3e75ed8c25e5a615bfc739a6d15c635
6
+ metadata.gz: a567f41bee5bbedd913786a18623285f45303f2b7f45e13c0a60171bead24ab70b65e51f9231043f857128e30e5229688fa553a07a3799b768fc89b799654881
7
+ data.tar.gz: 53b910c7287fe30d1c2ef9f0796f9e4de5e868a058db982f3e421efb0d2494b03683ed3202d814e5431a1f8902493a3a09d1023d280665cd8fc38b4b18df53d3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
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
+
3
36
  ## 0.3.0
4
37
 
5
38
  - Webhook amount fields (Waffo, 2026-09-20 to 2026-09-22):
data/README.md CHANGED
@@ -100,13 +100,15 @@ settings: the portal's return button links only there.
100
100
 
101
101
  ```ruby
102
102
  client.cancel_subscription("ORD_...") # => {"orderId" => "ORD_...", "status" => "canceling"}
103
- client.reactivate_subscription("ORD_...") # while still canceling
104
103
  client.subscription_order("ORD_...") # GraphQL; nil when unknown
105
104
  # => {"status" => "active", "currentPeriodEnd" => "2027-10-08T...", "merchantProvidedBuyerIdentity" => "user-42", ...}
106
105
  ```
107
106
 
108
107
  `canceling` keeps access until `currentPeriodEnd`. The terminal `canceled` arrives as a webhook.
109
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
+
110
112
  ## Products
111
113
 
112
114
  ```ruby
@@ -119,11 +121,102 @@ client.publish_subscription_product(id: "PROD_...") # test → prod, first publ
119
121
 
120
122
  Amounts are display strings (`"9.99"`), not cents.
121
123
 
122
- ## GraphQL and other endpoints
124
+ ## Stores, coupons and refunds
123
125
 
124
126
  ```ruby
125
- client.graphql("query ($id: String!) { onetimeOrder(id: $id) { id status } }", id: "ORD_...")
126
- client.action("/v1/actions/refund-ticket/create-ticket", { payment_id: "PAY_...", reason: "..." })
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:
181
+
182
+ ```ruby
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: "..." })
127
220
  ```
128
221
 
129
222
  ## Webhooks
@@ -302,14 +395,15 @@ end
302
395
 
303
396
  `FakeClient` answers `subscription_order` from the orders it was given, moves an order to
304
397
  `canceling` / `active` on cancel / reactivate, records every call in `calls`, and raises
305
- `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.
306
400
 
307
401
  ## Errors
308
402
 
309
403
  | Class | When |
310
404
  |---|---|
311
405
  | `Waffo::Pancake::Rejected` | Waffo refused the request (4xx other than 429). `status` and `errors` carry the details. |
312
- | `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. |
313
407
  | `Waffo::Pancake::ConfigurationError` | Missing merchant id, or a key that does not parse. |
314
408
  | `Waffo::Pancake::InvalidSignature` | A webhook that does not verify. |
315
409
 
@@ -318,7 +412,7 @@ All inherit from `Waffo::Pancake::Error`.
318
412
  ## Testing your code
319
413
 
320
414
  `Client.new` takes a `transport:` callable that replaces the HTTP call. It receives the URI,
321
- 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]`.
322
416
 
323
417
  ```ruby
324
418
  transport = ->(uri, headers, json) { [200, '{"data":{"orderId":"ORD_1","status":"canceling"}}'] }
@@ -334,6 +428,17 @@ bundle install
334
428
  bundle exec rake test # test:core (no Rails loaded) and test:rails
335
429
  ```
336
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
+
337
442
  Releases are published to RubyGems by the `Release` workflow when a `v*` tag is pushed (see
338
443
  [RELEASING.md](RELEASING.md)). It uses
339
444
  [trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no API key is stored.
@@ -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