waffo-pancake 0.1.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 +7 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE +21 -0
- data/README.md +153 -0
- data/lib/waffo/pancake/client.rb +231 -0
- data/lib/waffo/pancake/errors.rb +29 -0
- data/lib/waffo/pancake/keys.rb +51 -0
- data/lib/waffo/pancake/version.rb +7 -0
- data/lib/waffo/pancake/webhook.rb +127 -0
- data/lib/waffo/pancake.rb +13 -0
- metadata +69 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c5283cad4a209984c83a1416a5b8a56ba039935e1d7c4ed3879349f5eeb6e5f7
|
|
4
|
+
data.tar.gz: 76a97790c5458a0ce01f9edc5c88f54bada6b04d29abd2904f768b35b06d4213
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9f96eae8643cd2f8bc76c8ad36c4b48f991b0df654a1c696bf5cb53c5bbe0a48f9fa291809e3a3370aed504d3b7aeeda34b959ad91312cc62f1d21c0c0c95569
|
|
7
|
+
data.tar.gz: 21cc4f957a948cce53f880578094f8fece93ce4cc58f73500d1d82b0f8f3ef62d41f3ac012d4df92a42e017b99e53c85a269475445277b397938376714bebc4c
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- `Waffo::Pancake::Client`: signed merchant API requests (RSA-SHA256, `X-Merchant-Id` /
|
|
6
|
+
`X-Timestamp` / `X-Signature`), checkout sessions (anonymous and authenticated), subscription
|
|
7
|
+
cancel and reactivate, one-time order cancel, subscription and one-time products, webhook
|
|
8
|
+
endpoints, GraphQL and `subscription_order`.
|
|
9
|
+
- `Waffo::Pancake::Webhook.verify`: `X-Waffo-Signature` verification with the built-in test and
|
|
10
|
+
prod platform keys, environment-variable overrides and replay tolerance.
|
|
11
|
+
- Private and public keys accepted as PEM, PEM with literal `\n`, or bare base64.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Good First Issue
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# waffo-pancake
|
|
2
|
+
|
|
3
|
+
Ruby client for the [Waffo Pancake](https://docs.waffo.ai) merchant API. It signs requests with
|
|
4
|
+
your API key, creates checkout sessions, manages subscriptions and products, runs GraphQL
|
|
5
|
+
queries, and verifies webhook signatures. It is a Ruby port of the official TypeScript SDK,
|
|
6
|
+
[`@waffo/pancake-ts`](https://www.npmjs.com/package/@waffo/pancake-ts), built on the standard
|
|
7
|
+
library (`net/http`, `openssl`, `json`) plus the `base64` gem.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "waffo-pancake"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Configure
|
|
16
|
+
|
|
17
|
+
Create an API key in the Dashboard (**API & Development**). A key is bound to test or prod
|
|
18
|
+
when it is created, so the key decides the environment.
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
require "waffo/pancake"
|
|
22
|
+
|
|
23
|
+
client = Waffo::Pancake::Client.new(
|
|
24
|
+
merchant_id: ENV["WAFFO_MERCHANT_ID"], # MER_...
|
|
25
|
+
private_key: ENV["WAFFO_PRIVATE_KEY"], # PEM, PEM with literal \n, or bare base64
|
|
26
|
+
logger: Rails.logger # optional: one line per request, never the body
|
|
27
|
+
)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Both arguments default to those environment variables. Keep the private key on the server.
|
|
31
|
+
|
|
32
|
+
## Checkout
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
# Authenticated checkout (recommended): buyer_identity is your stable id for the customer.
|
|
36
|
+
# Waffo uses it for trial eligibility and the customer portal.
|
|
37
|
+
session = client.create_authenticated_checkout(
|
|
38
|
+
product_id: "PROD_...",
|
|
39
|
+
currency: "USD",
|
|
40
|
+
buyer_identity: "user-42",
|
|
41
|
+
buyer_email: "ada@example.com",
|
|
42
|
+
success_url: "https://example.com/settings?checkout=success",
|
|
43
|
+
order_merchant_external_id: "user-42", # comes back on every webhook for the order
|
|
44
|
+
metadata: { "user_id" => "42" }
|
|
45
|
+
)
|
|
46
|
+
redirect_to session["checkoutUrl"], allow_other_host: true # ends in #token=...
|
|
47
|
+
|
|
48
|
+
# Anonymous checkout: the buyer fills everything in on the page.
|
|
49
|
+
client.create_checkout_session(product_id: "PROD_...", currency: "USD")
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Top-level keyword arguments may be snake_case and are sent as camelCase. Nested hashes
|
|
53
|
+
(`metadata`, `prices`, `billing_detail`'s fields) are sent exactly as given.
|
|
54
|
+
|
|
55
|
+
## Subscriptions
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
client.cancel_subscription("ORD_...") # => {"orderId" => "ORD_...", "status" => "canceling"}
|
|
59
|
+
client.reactivate_subscription("ORD_...") # while still canceling
|
|
60
|
+
client.subscription_order("ORD_...") # GraphQL; nil when unknown
|
|
61
|
+
# => {"status" => "active", "currentPeriodEnd" => "2027-10-08T...", "merchantProvidedBuyerIdentity" => "user-42", ...}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`canceling` keeps access until `currentPeriodEnd`. The terminal `canceled` arrives as a webhook.
|
|
65
|
+
|
|
66
|
+
## Products
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
client.create_subscription_product(
|
|
70
|
+
store_id: "STO_...", name: "Pro", billing_period: "yearly",
|
|
71
|
+
prices: { "USD" => { amount: "9.99", taxCategory: "saas" } }
|
|
72
|
+
)
|
|
73
|
+
client.publish_subscription_product(id: "PROD_...") # test → prod, first publish only
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Amounts are display strings (`"9.99"`), not cents.
|
|
77
|
+
|
|
78
|
+
## GraphQL and other endpoints
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
client.graphql("query ($id: String!) { onetimeOrder(id: $id) { id status } }", id: "ORD_...")
|
|
82
|
+
client.action("/v1/actions/refund-ticket/create-ticket", { payment_id: "PAY_...", reason: "..." })
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Webhooks
|
|
86
|
+
|
|
87
|
+
Pass the raw body; a body that was parsed and re-serialized will not verify.
|
|
88
|
+
|
|
89
|
+
```ruby
|
|
90
|
+
class WaffoWebhooksController < ActionController::Base
|
|
91
|
+
skip_forgery_protection
|
|
92
|
+
|
|
93
|
+
def create
|
|
94
|
+
event = Waffo::Pancake::Webhook.verify(
|
|
95
|
+
request.raw_post,
|
|
96
|
+
request.headers["X-Waffo-Signature"],
|
|
97
|
+
environment: "prod" # or "test"; omit to try prod, then test
|
|
98
|
+
)
|
|
99
|
+
# Deduplicate on event["id"]: a retry redelivers the same id.
|
|
100
|
+
case event["eventType"]
|
|
101
|
+
when "subscription.activated", "subscription.renewed" then # grant access until data.currentPeriodEnd
|
|
102
|
+
when "subscription.canceled" then # revoke access
|
|
103
|
+
end
|
|
104
|
+
head :ok
|
|
105
|
+
rescue Waffo::Pancake::InvalidSignature
|
|
106
|
+
head :unauthorized
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The platform's public keys are built in. When Waffo rotates them, set
|
|
112
|
+
`WAFFO_WEBHOOK_PROD_PUBLIC_KEY` / `WAFFO_WEBHOOK_TEST_PUBLIC_KEY` (or `WAFFO_WEBHOOK_PUBLIC_KEY` for
|
|
113
|
+
both), or pass `public_key:`. Timestamps may be up to 45 minutes old and 1 minute ahead by default
|
|
114
|
+
(`tolerance:`, `future_tolerance:`, in seconds; `tolerance: 0` turns the check off).
|
|
115
|
+
|
|
116
|
+
`Waffo::Pancake::Webhook::EVENT_TYPES` lists the event types.
|
|
117
|
+
|
|
118
|
+
## Errors
|
|
119
|
+
|
|
120
|
+
| Class | When |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `Waffo::Pancake::Rejected` | Waffo refused the request (4xx other than 429). `status` and `errors` carry the details. |
|
|
123
|
+
| `Waffo::Pancake::Unavailable` | Network failure, timeout, 429 or 5xx. Worth retrying later. |
|
|
124
|
+
| `Waffo::Pancake::ConfigurationError` | Missing merchant id, or a key that does not parse. |
|
|
125
|
+
| `Waffo::Pancake::InvalidSignature` | A webhook that does not verify. |
|
|
126
|
+
|
|
127
|
+
All inherit from `Waffo::Pancake::Error`.
|
|
128
|
+
|
|
129
|
+
## Testing your code
|
|
130
|
+
|
|
131
|
+
`Client.new` takes a `transport:` callable that replaces the HTTP call. It receives the URI,
|
|
132
|
+
the headers and the JSON body and returns `[status, body]`.
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
transport = ->(uri, headers, json) { [200, '{"data":{"orderId":"ORD_1","status":"canceling"}}'] }
|
|
136
|
+
client = Waffo::Pancake::Client.new(merchant_id: "MER_x", private_key: key, transport: transport)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Test cards: `4576 7500 0000 0110` succeeds and `4576 7500 0000 0220` is declined.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
```sh
|
|
144
|
+
bundle install
|
|
145
|
+
bundle exec rake test
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Releases are published to RubyGems by the `Release` workflow when a `v*` tag is pushed. It uses
|
|
149
|
+
[trusted publishing](https://guides.rubygems.org/trusted-publishing/), so no API key is stored.
|
|
150
|
+
|
|
151
|
+
## License
|
|
152
|
+
|
|
153
|
+
MIT
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "uri"
|
|
8
|
+
|
|
9
|
+
module Waffo
|
|
10
|
+
module Pancake
|
|
11
|
+
# The merchant API, authenticated with an API key: every request is a POST signed with the
|
|
12
|
+
# key's RSA private key. An API key is bound to test or prod when it is created, so no
|
|
13
|
+
# environment header is sent.
|
|
14
|
+
#
|
|
15
|
+
# client = Waffo::Pancake::Client.new(merchant_id: "MER_...", private_key: ENV["WAFFO_PRIVATE_KEY"])
|
|
16
|
+
# client.cancel_subscription("ORD_...") # => {"orderId" => "ORD_...", "status" => "canceling"}
|
|
17
|
+
#
|
|
18
|
+
# Parameters may be given in snake_case; top-level keys are sent in camelCase. Nested
|
|
19
|
+
# hashes (metadata, prices, billing details) are sent exactly as given.
|
|
20
|
+
class Client
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.waffo.ai"
|
|
22
|
+
DEFAULT_TIMEOUT = 15
|
|
23
|
+
|
|
24
|
+
NETWORK_ERRORS = [
|
|
25
|
+
Timeout::Error, SocketError, SystemCallError, IOError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
SUBSCRIPTION_ORDER_QUERY = <<~GRAPHQL
|
|
29
|
+
query ($id: String!) {
|
|
30
|
+
subscriptionOrder(id: $id) {
|
|
31
|
+
id storeId status testMode buyerEmail merchantProvidedBuyerIdentity orderMerchantExternalId
|
|
32
|
+
billingPeriod currentPeriodStart currentPeriodEnd canceledAt
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
GRAPHQL
|
|
36
|
+
|
|
37
|
+
attr_reader :merchant_id, :base_url
|
|
38
|
+
|
|
39
|
+
# The canonical request the gateway checks `X-Signature` against: method, path,
|
|
40
|
+
# timestamp (seconds) and the base64 SHA-256 of the body, one per line.
|
|
41
|
+
def self.sign(method:, path:, timestamp:, body:, private_key:)
|
|
42
|
+
body_hash = Base64.strict_encode64(OpenSSL::Digest::SHA256.digest(body))
|
|
43
|
+
canonical = "#{method}\n#{path}\n#{timestamp}\n#{body_hash}"
|
|
44
|
+
Base64.strict_encode64(private_key.sign(OpenSSL::Digest::SHA256.new, canonical))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# `transport` replaces the HTTP call: it takes the URI, the headers and the JSON body
|
|
48
|
+
# and answers `[status, body]` (status nil when there was no answer). `logger` gets one
|
|
49
|
+
# info line per request; neither the body nor the key is ever logged.
|
|
50
|
+
def initialize(merchant_id: ENV["WAFFO_MERCHANT_ID"], private_key: ENV["WAFFO_PRIVATE_KEY"],
|
|
51
|
+
base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, logger: nil, transport: nil)
|
|
52
|
+
raise ConfigurationError, "Missing merchant_id (WAFFO_MERCHANT_ID)" if blank?(merchant_id)
|
|
53
|
+
raise ConfigurationError, "Missing private_key (WAFFO_PRIVATE_KEY)" if blank?(private_key)
|
|
54
|
+
|
|
55
|
+
@merchant_id = merchant_id
|
|
56
|
+
@private_key = Keys.private_key(private_key)
|
|
57
|
+
@base_url = base_url.to_s.chomp("/")
|
|
58
|
+
@timeout = timeout
|
|
59
|
+
@logger = logger
|
|
60
|
+
@transport = transport || method(:http_post)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# --- Checkout ----------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
def issue_session_token(buyer_identity:, store_id: nil, product_id: nil)
|
|
66
|
+
raise ArgumentError, "store_id or product_id is required" if store_id.nil? && product_id.nil?
|
|
67
|
+
|
|
68
|
+
action("/v1/actions/auth/issue-session-token",
|
|
69
|
+
{ buyer_identity: buyer_identity, store_id: store_id, product_id: product_id })
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Anonymous checkout: the buyer fills everything in on the page.
|
|
73
|
+
# Returns {"sessionId", "checkoutUrl", "expiresAt"}.
|
|
74
|
+
def create_checkout_session(product_id:, currency:, **params)
|
|
75
|
+
action("/v1/actions/checkout/create-session", { product_id: product_id, currency: currency, **params })
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Authenticated checkout: `buyer_identity` (your stable id for the customer) goes into a
|
|
79
|
+
# session token, which Waffo uses for trial eligibility and the customer portal. The
|
|
80
|
+
# token is appended to the checkout URL as a fragment (`#token=...`).
|
|
81
|
+
def create_authenticated_checkout(product_id:, currency:, buyer_identity:, **params)
|
|
82
|
+
token = issue_session_token(buyer_identity: buyer_identity, product_id: product_id)
|
|
83
|
+
session = create_checkout_session(product_id: product_id, currency: currency, **params)
|
|
84
|
+
session.merge(
|
|
85
|
+
"checkoutUrl" => "#{session.fetch("checkoutUrl")}#token=#{token.fetch("token")}",
|
|
86
|
+
"token" => token["token"],
|
|
87
|
+
"tokenExpiresAt" => token["expiresAt"]
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# --- Orders ------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
# pending → canceled at once; active, trialing and past_due → canceling, ending with the
|
|
94
|
+
# paid period. The terminal `canceled` arrives later as a webhook.
|
|
95
|
+
def cancel_subscription(order_id)
|
|
96
|
+
action("/v1/actions/subscription-order/cancel-order", { order_id: order_id })
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Undoes a cancellation while the order is still `canceling`.
|
|
100
|
+
def reactivate_subscription(order_id)
|
|
101
|
+
action("/v1/actions/subscription-order/reactivate-order", { order_id: order_id })
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def cancel_onetime_order(order_id)
|
|
105
|
+
action("/v1/actions/onetime-order/cancel-order", { order_id: order_id })
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Nil when Waffo knows no such order.
|
|
109
|
+
def subscription_order(order_id)
|
|
110
|
+
graphql(SUBSCRIPTION_ORDER_QUERY, id: order_id)&.dig("subscriptionOrder")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# --- Products ----------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
# prices: { "USD" => { amount: "9.99", taxCategory: "saas" } }
|
|
116
|
+
def create_subscription_product(store_id:, name:, billing_period:, prices:, **params)
|
|
117
|
+
action("/v1/actions/subscription-product/create-product",
|
|
118
|
+
{ store_id: store_id, name: name, billing_period: billing_period, prices: prices, **params })
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def update_subscription_product(id:, **params)
|
|
122
|
+
action("/v1/actions/subscription-product/update-product", { id: id, **params })
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Copies the test version to production (first publish only).
|
|
126
|
+
def publish_subscription_product(id:)
|
|
127
|
+
action("/v1/actions/subscription-product/publish-product", { id: id })
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def create_onetime_product(store_id:, name:, prices:, **params)
|
|
131
|
+
action("/v1/actions/onetime-product/create-product", { store_id: store_id, name: name, prices: prices, **params })
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# --- Webhook endpoints -------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def add_webhook(store_id:, url:, events:, test_mode:, channel: "http", **params)
|
|
137
|
+
action("/v1/actions/store/add-webhook",
|
|
138
|
+
{ store_id: store_id, channel: channel, url: url, events: events, test_mode: test_mode, **params })
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def remove_webhook(id:)
|
|
142
|
+
action("/v1/actions/store/remove-webhook", { id: id })
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# --- Low level ---------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
# Returns `data`. Raises when the response carries `errors` and no data.
|
|
148
|
+
def graphql(query, variables = {})
|
|
149
|
+
status, envelope = request("/v1/graphql", { "query" => query, "variables" => variables })
|
|
150
|
+
raise_for(status, envelope, "/v1/graphql") if present?(envelope["errors"]) && envelope["data"].nil?
|
|
151
|
+
|
|
152
|
+
envelope["data"]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Any action endpoint. Returns the envelope's `data`; raises Rejected or Unavailable on
|
|
156
|
+
# `errors` or a non-2xx status.
|
|
157
|
+
def action(path, params)
|
|
158
|
+
status, envelope = request(path, camelize(params))
|
|
159
|
+
raise_for(status, envelope, path) if present?(envelope["errors"]) || !(200..299).cover?(status)
|
|
160
|
+
|
|
161
|
+
envelope["data"] || {}
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
def request(path, params)
|
|
166
|
+
json = JSON.generate(params)
|
|
167
|
+
timestamp = Time.now.to_i.to_s
|
|
168
|
+
headers = {
|
|
169
|
+
"Content-Type" => "application/json",
|
|
170
|
+
"X-Merchant-Id" => @merchant_id,
|
|
171
|
+
"X-Timestamp" => timestamp,
|
|
172
|
+
"X-Signature" => self.class.sign(method: "POST", path: path, timestamp: timestamp, body: json,
|
|
173
|
+
private_key: @private_key)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
177
|
+
status, body = call_transport(URI("#{@base_url}#{path}"), headers, json)
|
|
178
|
+
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).round(2)
|
|
179
|
+
@logger&.info("[Waffo] POST #{path} status=#{status.inspect} in #{elapsed}s")
|
|
180
|
+
|
|
181
|
+
raise Unavailable.new("Waffo #{path} unreachable: #{body}", status: nil) if status.nil?
|
|
182
|
+
|
|
183
|
+
envelope = parse(body)
|
|
184
|
+
raise_for(status, {}, path, "non-JSON response") unless envelope.is_a?(Hash)
|
|
185
|
+
|
|
186
|
+
[status, envelope]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def call_transport(uri, headers, json)
|
|
190
|
+
@transport.call(uri, headers, json)
|
|
191
|
+
rescue *NETWORK_ERRORS => e
|
|
192
|
+
[nil, "#{e.class}: #{e.message}"]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def http_post(uri, headers, json)
|
|
196
|
+
request = Net::HTTP::Post.new(uri, headers)
|
|
197
|
+
request.body = json
|
|
198
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout,
|
|
199
|
+
read_timeout: @timeout, write_timeout: @timeout) do |http|
|
|
200
|
+
http.request(request)
|
|
201
|
+
end
|
|
202
|
+
[response.code.to_i, response.body]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def raise_for(status, envelope, path, fallback = "request failed")
|
|
206
|
+
errors = Array(envelope["errors"])
|
|
207
|
+
message = errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.first || fallback
|
|
208
|
+
error = status.nil? || status == 429 || status >= 500 ? Unavailable : Rejected
|
|
209
|
+
raise error.new("Waffo #{path} status=#{status}: #{message}", status: status, errors: errors)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def parse(body)
|
|
213
|
+
JSON.parse(body.to_s)
|
|
214
|
+
rescue JSON::ParserError
|
|
215
|
+
nil
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def camelize(params)
|
|
219
|
+
params.each_with_object({}) do |(key, value), out|
|
|
220
|
+
next if value.nil?
|
|
221
|
+
|
|
222
|
+
out[key.to_s.gsub(/_([a-z\d])/) { Regexp.last_match(1).upcase }] = value
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def blank?(value) = value.nil? || value.to_s.strip.empty?
|
|
227
|
+
|
|
228
|
+
def present?(value) = !(value.nil? || (value.respond_to?(:empty?) && value.empty?))
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Waffo
|
|
4
|
+
module Pancake
|
|
5
|
+
# Base class. `status` is the HTTP status (nil when the request never got an answer) and
|
|
6
|
+
# `errors` the envelope's `errors` array, as Waffo sent it.
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
attr_reader :status, :errors
|
|
9
|
+
|
|
10
|
+
def initialize(message = nil, status: nil, errors: [])
|
|
11
|
+
super(message)
|
|
12
|
+
@status = status
|
|
13
|
+
@errors = errors
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The key, merchant id or other setting is missing or unusable.
|
|
18
|
+
class ConfigurationError < Error; end
|
|
19
|
+
|
|
20
|
+
# Worth another try later: a network failure, a timeout, a 429 or a 5xx.
|
|
21
|
+
class Unavailable < Error; end
|
|
22
|
+
|
|
23
|
+
# Not worth another try as is: Waffo refused the request (validation, state, auth).
|
|
24
|
+
class Rejected < Error; end
|
|
25
|
+
|
|
26
|
+
# A webhook whose signature, header or timestamp does not check out.
|
|
27
|
+
class InvalidSignature < Error; end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Waffo
|
|
6
|
+
module Pancake
|
|
7
|
+
# Turns the shapes keys arrive in (PEM, PEM with literal "\n" from an env file, CRLF,
|
|
8
|
+
# a one-line body, bare base64) into OpenSSL keys, as @waffo/pancake-ts does.
|
|
9
|
+
module Keys
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def private_key(raw)
|
|
13
|
+
return raw if raw.is_a?(OpenSSL::PKey::RSA)
|
|
14
|
+
|
|
15
|
+
pem = clean(raw, "private")
|
|
16
|
+
kind = pem.include?("BEGIN RSA PRIVATE KEY") ? "RSA PRIVATE KEY" : "PRIVATE KEY"
|
|
17
|
+
OpenSSL::PKey::RSA.new(wrap(body_of(pem, "PRIVATE"), kind))
|
|
18
|
+
rescue OpenSSL::PKey::PKeyError, ArgumentError
|
|
19
|
+
raise ConfigurationError, "The private key is not a valid RSA private key (PKCS#8 or PKCS#1)"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def public_key(raw)
|
|
23
|
+
return raw if raw.is_a?(OpenSSL::PKey::RSA)
|
|
24
|
+
|
|
25
|
+
pem = clean(raw, "public")
|
|
26
|
+
kind = pem.include?("BEGIN RSA PUBLIC KEY") ? "RSA PUBLIC KEY" : "PUBLIC KEY"
|
|
27
|
+
OpenSSL::PKey::RSA.new(wrap(body_of(pem, "PUBLIC"), kind))
|
|
28
|
+
rescue OpenSSL::PKey::PKeyError, ArgumentError
|
|
29
|
+
raise ConfigurationError, "The public key is not a valid RSA public key (SPKI or PKCS#1)"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def clean(raw, label)
|
|
33
|
+
pem = raw.to_s.gsub("\\n", "\n").gsub("\r\n", "\n").strip
|
|
34
|
+
raise ConfigurationError, "The #{label} key is empty" if pem.empty?
|
|
35
|
+
|
|
36
|
+
pem
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def body_of(pem, label)
|
|
40
|
+
body = pem.gsub(/-----(BEGIN|END) (RSA )?#{label} KEY-----/, "").gsub(/\s+/, "")
|
|
41
|
+
raise ArgumentError, "no key data" unless body.match?(%r{\A[A-Za-z0-9+/]+=*\z})
|
|
42
|
+
|
|
43
|
+
body
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def wrap(body, kind)
|
|
47
|
+
"-----BEGIN #{kind}-----\n#{body.scan(/.{1,64}/).join("\n")}\n-----END #{kind}-----\n"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
|
|
7
|
+
module Waffo
|
|
8
|
+
module Pancake
|
|
9
|
+
# Verifies a delivery the way @waffo/pancake-ts's verifyWebhook does.
|
|
10
|
+
#
|
|
11
|
+
# event = Waffo::Pancake::Webhook.verify(request.raw_post, request.headers["X-Waffo-Signature"],
|
|
12
|
+
# environment: "prod")
|
|
13
|
+
# event["eventType"] # => "subscription.activated"
|
|
14
|
+
#
|
|
15
|
+
# The header is `t=<unix ms>,v1=<base64>`: an RSA-SHA256 signature over "<t>.<raw body>".
|
|
16
|
+
# Pass the body exactly as received; a re-serialized JSON will not verify.
|
|
17
|
+
module Webhook
|
|
18
|
+
SIGNATURE_HEADER = "X-Waffo-Signature"
|
|
19
|
+
|
|
20
|
+
# Retries reuse the first attempt's header, so the past-facing window covers a whole
|
|
21
|
+
# retry schedule. Deduplicate on the event's `id`; the window only bounds replays.
|
|
22
|
+
DEFAULT_TOLERANCE = 45 * 60
|
|
23
|
+
DEFAULT_FUTURE_TOLERANCE = 60
|
|
24
|
+
|
|
25
|
+
ENVIRONMENTS = %w[test prod].freeze
|
|
26
|
+
|
|
27
|
+
# Waffo's platform keys, as embedded in @waffo/pancake-ts 0.25.0.
|
|
28
|
+
PUBLIC_KEYS = {
|
|
29
|
+
"test" => <<~PEM,
|
|
30
|
+
-----BEGIN PUBLIC KEY-----
|
|
31
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxnmRY6yMMA3lVqmAU6ZG
|
|
32
|
+
b1sjL/+r/z6E+ZjkXaDAKiqOhk9rpazni0bNsGXwmftTPk9jy2wn+j6JHODD/WH/
|
|
33
|
+
SCnSfvKkLIjy4Hk7BuCgB174C0ydan7J+KgXLkOwgCAxxB68t2tezldwo74ZpXgn
|
|
34
|
+
F49opzMvQ9prEwIAWOE+kV9iK6gx/AckSMtHIHpUesoPDkldpmFHlB2qpf1vsFTZ
|
|
35
|
+
5kD6DmGl+2GIVK01aChy2lk8pLv0yUMu18v44sLkO5M44TkGPJD9qG09wrvVG2wp
|
|
36
|
+
OTVCn1n5pP8P+HRLcgzbUB3OlZVfdFurn6EZwtyL4ZD9kdkQ4EZE/9inKcp3c1h4
|
|
37
|
+
xwIDAQAB
|
|
38
|
+
-----END PUBLIC KEY-----
|
|
39
|
+
PEM
|
|
40
|
+
"prod" => <<~PEM
|
|
41
|
+
-----BEGIN PUBLIC KEY-----
|
|
42
|
+
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz+xApdTIb4ua+DgZKQ54
|
|
43
|
+
iBsD82ybyhGCLRETONW4Jgbb3A8DUM1LqBk6r/CmTOCHqLalTQHNigvP3R5zkDNX
|
|
44
|
+
iRJz6gA4MJ/+8K0+mnEE2RISQzN+Qu65TNd6svb+INm/kMaftY4uIXr6y6kchtTJ
|
|
45
|
+
dwnQhcKdAL2v7h7IFnkVelQsKxDdb2PqX8xX/qwd01iXvMcpCCaXovUwZsxH2QN5
|
|
46
|
+
ZKBTseJivbhUeyJCco4fdUyxOMHe2ybCVhyvim2uxAl1nkvL5L8RCWMCAV55LLo0
|
|
47
|
+
9OhmLahz/DYNu13YLVP6dvIT09ZFBYU6Owj1NxdinTynlJCFS9VYwBgmftosSE1U
|
|
48
|
+
dwIDAQAB
|
|
49
|
+
-----END PUBLIC KEY-----
|
|
50
|
+
PEM
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
EVENT_TYPES = %w[
|
|
54
|
+
order.completed
|
|
55
|
+
subscription.activated subscription.payment_succeeded subscription.renewed subscription.recovered
|
|
56
|
+
subscription.plan_changed subscription.plan_change_scheduled subscription.plan_change_failed
|
|
57
|
+
subscription.canceling subscription.uncanceled subscription.canceled subscription.past_due
|
|
58
|
+
refund.succeeded refund.failed
|
|
59
|
+
].freeze
|
|
60
|
+
|
|
61
|
+
module_function
|
|
62
|
+
|
|
63
|
+
# Returns the parsed event. Raises InvalidSignature.
|
|
64
|
+
#
|
|
65
|
+
# environment: "test" or "prod" uses that environment's key; nil tries prod, then test.
|
|
66
|
+
# public_key: a key to use instead (skips the lookup and `environment`).
|
|
67
|
+
# tolerance: seconds a timestamp may lie in the past; 0 skips the timestamp check.
|
|
68
|
+
def verify(payload, signature_header, environment: nil, public_key: nil, tolerance: DEFAULT_TOLERANCE,
|
|
69
|
+
future_tolerance: DEFAULT_FUTURE_TOLERANCE, now: Time.now)
|
|
70
|
+
timestamp, signature = parse_header(signature_header)
|
|
71
|
+
check_timestamp!(timestamp, now, tolerance, future_tolerance) if tolerance.positive?
|
|
72
|
+
|
|
73
|
+
input = "#{timestamp}.#{payload}"
|
|
74
|
+
keys = public_key ? [Keys.public_key(public_key)] : keys_for(environment)
|
|
75
|
+
raise InvalidSignature, "Invalid webhook signature" unless keys.any? { |key| valid?(key, signature, input) }
|
|
76
|
+
|
|
77
|
+
JSON.parse(payload.to_s)
|
|
78
|
+
rescue JSON::ParserError
|
|
79
|
+
raise InvalidSignature, "Webhook body is not JSON"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The key for an environment: WAFFO_WEBHOOK_{TEST,PROD}_PUBLIC_KEY, then
|
|
83
|
+
# WAFFO_WEBHOOK_PUBLIC_KEY, then the built-in platform key.
|
|
84
|
+
def public_key_for(environment)
|
|
85
|
+
environment = environment.to_s
|
|
86
|
+
raise ArgumentError, "environment must be test or prod" unless ENVIRONMENTS.include?(environment)
|
|
87
|
+
|
|
88
|
+
custom = ENV["WAFFO_WEBHOOK_#{environment.upcase}_PUBLIC_KEY"] || ENV["WAFFO_WEBHOOK_PUBLIC_KEY"]
|
|
89
|
+
Keys.public_key(custom.to_s.strip.empty? ? PUBLIC_KEYS.fetch(environment) : custom)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def keys_for(environment)
|
|
93
|
+
environment ? [public_key_for(environment)] : [public_key_for("prod"), public_key_for("test")]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_header(header)
|
|
97
|
+
raise InvalidSignature, "Missing #{SIGNATURE_HEADER} header" if header.nil? || header.to_s.strip.empty?
|
|
98
|
+
|
|
99
|
+
parts = header.to_s.split(",").each_with_object({}) do |pair, out|
|
|
100
|
+
key, value = pair.split("=", 2)
|
|
101
|
+
out[key.strip] = value.strip if value
|
|
102
|
+
end
|
|
103
|
+
timestamp, signature = parts["t"], parts["v1"]
|
|
104
|
+
if timestamp.to_s.empty? || signature.to_s.empty?
|
|
105
|
+
raise InvalidSignature, "Malformed #{SIGNATURE_HEADER} header: missing t or v1"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
[timestamp, signature]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def check_timestamp!(timestamp, now, tolerance, future_tolerance)
|
|
112
|
+
raise InvalidSignature, "Invalid timestamp in #{SIGNATURE_HEADER} header" unless timestamp.match?(/\A\d+\z/)
|
|
113
|
+
|
|
114
|
+
age = now.to_f - (timestamp.to_i / 1000.0)
|
|
115
|
+
return if age <= tolerance && age >= -future_tolerance
|
|
116
|
+
|
|
117
|
+
raise InvalidSignature, "Webhook timestamp outside tolerance window (possible replay attack)"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def valid?(key, signature, input)
|
|
121
|
+
key.verify(OpenSSL::Digest::SHA256.new, Base64.strict_decode64(signature), input)
|
|
122
|
+
rescue ArgumentError, OpenSSL::PKey::PKeyError
|
|
123
|
+
false
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pancake/version"
|
|
4
|
+
require_relative "pancake/errors"
|
|
5
|
+
require_relative "pancake/keys"
|
|
6
|
+
require_relative "pancake/client"
|
|
7
|
+
require_relative "pancake/webhook"
|
|
8
|
+
|
|
9
|
+
module Waffo
|
|
10
|
+
# Ruby client for the Waffo Pancake merchant API (https://docs.waffo.ai).
|
|
11
|
+
module Pancake
|
|
12
|
+
end
|
|
13
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: waffo-pancake
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Good First Issue
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
description: Signed requests to the Waffo Pancake merchant API (checkout, subscriptions,
|
|
27
|
+
products, GraphQL) and webhook signature verification. A Ruby port of @waffo/pancake-ts.
|
|
28
|
+
email:
|
|
29
|
+
- hi@goodfirstissue.org
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- CHANGELOG.md
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- lib/waffo/pancake.rb
|
|
38
|
+
- lib/waffo/pancake/client.rb
|
|
39
|
+
- lib/waffo/pancake/errors.rb
|
|
40
|
+
- lib/waffo/pancake/keys.rb
|
|
41
|
+
- lib/waffo/pancake/version.rb
|
|
42
|
+
- lib/waffo/pancake/webhook.rb
|
|
43
|
+
homepage: https://github.com/goodfirstissueorg/waffo-pancake-ruby
|
|
44
|
+
licenses:
|
|
45
|
+
- MIT
|
|
46
|
+
metadata:
|
|
47
|
+
homepage_uri: https://github.com/goodfirstissueorg/waffo-pancake-ruby
|
|
48
|
+
source_code_uri: https://github.com/goodfirstissueorg/waffo-pancake-ruby/tree/main
|
|
49
|
+
changelog_uri: https://github.com/goodfirstissueorg/waffo-pancake-ruby/blob/main/CHANGELOG.md
|
|
50
|
+
bug_tracker_uri: https://github.com/goodfirstissueorg/waffo-pancake-ruby/issues
|
|
51
|
+
rubygems_mfa_required: 'true'
|
|
52
|
+
rdoc_options: []
|
|
53
|
+
require_paths:
|
|
54
|
+
- lib
|
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '3.1'
|
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '0'
|
|
65
|
+
requirements: []
|
|
66
|
+
rubygems_version: 4.0.3
|
|
67
|
+
specification_version: 4
|
|
68
|
+
summary: Ruby client for the Waffo Pancake merchant API
|
|
69
|
+
test_files: []
|