sumwerk 0.2.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/LICENSE +21 -0
- data/README.md +84 -0
- data/lib/sumwerk/api.rb +268 -0
- data/lib/sumwerk/billing/answer.rb +116 -0
- data/lib/sumwerk/billing/client.rb +195 -0
- data/lib/sumwerk/billing/rack.rb +44 -0
- data/lib/sumwerk/billing/stores.rb +116 -0
- data/lib/sumwerk/billing/tokens.rb +39 -0
- data/lib/sumwerk/billing.rb +20 -0
- data/lib/sumwerk/client.rb +113 -0
- data/lib/sumwerk/errors.rb +33 -0
- data/lib/sumwerk/version.rb +3 -0
- data/lib/sumwerk.rb +14 -0
- metadata +72 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: d38a9abf2902acc55d8f5474c36ad67db25a4482223b66ac5b9a1a2d7ce05321
|
|
4
|
+
data.tar.gz: 3b5f84316a0ba01131a9816c9b474cb8f4ca9559b4105a8f41bb96c6403a380b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 768dfd41b92ad3c4f7cfddbd19018a02c3731949e2bc2fddc73125eb9599472a4b96b11922526d1dba6cdab619e55d91d50ed6fea1b95bac9b3bf7e59613abe9
|
|
7
|
+
data.tar.gz: 5b1b8b334f57af2115f5cc7f9658a418ae672b984b4269ffa2a7c68a4b55889f962c9cc9406e42c95b29613dcb9f51cb3b31733b6cc7f31f5474dc01d5ca7640
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kitchn Venture GmbH
|
|
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,84 @@
|
|
|
1
|
+
# sumwerk for Ruby
|
|
2
|
+
|
|
3
|
+
The sumwerk API for Ruby 3.2 and newer: every call with errors and retries, and your customers' billing answers from a
|
|
4
|
+
local copy. Reference: https://www.sumwerk.com/docs/api
|
|
5
|
+
|
|
6
|
+
```ruby
|
|
7
|
+
# Gemfile
|
|
8
|
+
gem "sumwerk"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## The API
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
sumwerk = Sumwerk::Client.new(token: ENV.fetch("SUMWERK_TOKEN")) # Account → API and exports
|
|
15
|
+
|
|
16
|
+
sumwerk.customers.upsert("nordwind", org.id, name: org.name, email: org.billing_email, stripe_customer_ids: [ org.stripe_id ])
|
|
17
|
+
sumwerk.customers.retrieve(org.id, source: "nordwind")
|
|
18
|
+
sumwerk.customers.each(status: "paying") { |customer| puts customer["name"] } # every page
|
|
19
|
+
sumwerk.usage.report_day("nordwind", Date.yesterday.iso8601, customers: { org.id => { "ads_launched" => 12 } })
|
|
20
|
+
sumwerk.notes.create(customer_id, body: "Called about the renewal", kind: "call")
|
|
21
|
+
sumwerk.tasks.create(customer_id, title: "Send the proposal", due_on: "2026-10-20")
|
|
22
|
+
sumwerk.mrr.retrieve(range: 12)
|
|
23
|
+
|
|
24
|
+
sumwerk.billing.report_total("nordwind", org.id, "seats", org.members.count)
|
|
25
|
+
sumwerk.billing.apply("nordwind", org.id, { type: "plan", plan: "business" }, idempotency_key: SecureRandom.uuid)
|
|
26
|
+
sumwerk.request(:get, "/api/v1/me") # anything the gem has no method for yet
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Every area of the API has its object: `customers`, `contacts`, `sources`, `usage`, `usage_metrics`, `attributes`,
|
|
30
|
+
`notes`, `tasks`, `research`, `conversations`, `deals`, `pipelines`, `emails`, `email_templates`, `sequences`,
|
|
31
|
+
`enrollments`, `mrr`, `movements`, `restatements`, `reconciliation`, `segments`, `lifecycle`, `plans`, `at_risk`,
|
|
32
|
+
`risk_rules`, `risk_exceptions`, `events`, `automations`, `log`, `webhook_requests`, `billing` (with
|
|
33
|
+
`billing.overrides` and `billing.plan_assignments`). Parameters are the API's, unchanged; answers are the API's JSON as
|
|
34
|
+
a Hash.
|
|
35
|
+
|
|
36
|
+
Errors raise a `Sumwerk::Error` with `status`, `code` (the API's `error`) and `message`: `InvalidRequestError` (400,
|
|
37
|
+
422), `AuthenticationError` (401), `PermissionError` (403), `NotFoundError` (404), `ConflictError` (409), `GoneError`
|
|
38
|
+
(410), `RateLimitError` (429, `retry_after`), `APIError` (5xx), `ConnectionError`. Timeouts, lost connections, 5xx and
|
|
39
|
+
429 are retried twice with growing waits for every call that is safe to send again: reads, `PUT`, `DELETE`, usage
|
|
40
|
+
events (each `event_id` counts once) and `apply` with an `idempotency_key`. Sending an email or starting a trial is
|
|
41
|
+
never repeated by itself.
|
|
42
|
+
|
|
43
|
+
## Billing answers without a call per check
|
|
44
|
+
|
|
45
|
+
Ask what an org may do in your app without a network call. sumwerk keeps a copy in your Redis current (its webhook, or
|
|
46
|
+
a periodic sync); your own uses count at once and go to sumwerk in batches. When nothing can be known, each
|
|
47
|
+
feature's default from your catalog answers, and sumwerk hears about it.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
ANSWERS = Sumwerk::Billing::Client.new(
|
|
51
|
+
app: "nordwind", token: ENV.fetch("SUMWERK_TOKEN"), # an account token with read and write access
|
|
52
|
+
store: Sumwerk::Billing::RedisStore.new(Redis.new),
|
|
53
|
+
webhook_secret: ENV.fetch("SUMWERK_WEBHOOK_SECRET"), browser_secret: ENV.fetch("SUMWERK_BROWSER_SECRET")
|
|
54
|
+
).start # flushes usage every few seconds
|
|
55
|
+
|
|
56
|
+
ANSWERS.entitled?(org.id, :reports) # a feature
|
|
57
|
+
ANSWERS.enabled?(org.id, :meta_languages) # a rollout flag
|
|
58
|
+
ANSWERS.allowed?(org.id, :ad_uploads, 3) # room for three more, within the tolerance?
|
|
59
|
+
ANSWERS.track(org.id, :ad_uploads, quantity: 3) # counted now, sent with the next batch
|
|
60
|
+
ANSWERS.report(org.id, :seats, org.users.count) # a total you count yourself
|
|
61
|
+
ANSWERS.upsert_customer(org.id, name: org.name, email: org.billing_email)
|
|
62
|
+
# the org as a customer, under your id (true when taken)
|
|
63
|
+
ANSWERS.limit(org.id, :ad_uploads) # used, remaining, state: within, near, over_in_tolerance, over, blocked
|
|
64
|
+
ANSWERS.browser_token(org.id) # for your page, to read the answer live
|
|
65
|
+
ANSWERS.page_token(org.id, user: user.id, email: user.email, role: "manage")
|
|
66
|
+
# opens sumwerk's customer pages once, within a minute ("view": no changes)
|
|
67
|
+
|
|
68
|
+
answer = ANSWERS.answer(org.id)
|
|
69
|
+
answer.plan_source # subscription, assignment, trial, after_cancellation, default, none
|
|
70
|
+
answer.trial # Trial(plan:, ends_on:) or nil: "14 days left of your Business trial"
|
|
71
|
+
answer.cancellation # Cancellation(plan:, ends_on:, ended_on:, until:, then:) or nil
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Mount the webhook and, for the browser, the read-only pass-through on your own domain (ad blockers leave it alone):
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
mount Sumwerk::Billing::Webhook.new(ANSWERS), at: "/sumwerk/billing/webhook"
|
|
78
|
+
mount Sumwerk::Billing::BrowserProxy.new, at: "/sumwerk/billing"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
On localhost (no webhook can reach you) run `ANSWERS.sync` every few seconds instead.
|
|
82
|
+
|
|
83
|
+
The API behind it, the webhook and the retry rules: https://www.sumwerk.com/docs/api#billing-what-each-customer-may-do. The
|
|
84
|
+
TypeScript package (`npm install sumwerk`) answers the same cases the same way. MIT licensed.
|
data/lib/sumwerk/api.rb
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
module Sumwerk
|
|
2
|
+
# One class per area of the API (https://www.sumwerk.com/docs/api). Ids in a path are escaped; parameters go as the
|
|
3
|
+
# query of a read and as the JSON body of a write, unchanged, so a parameter the API learns later works at once.
|
|
4
|
+
module Api
|
|
5
|
+
class Resource
|
|
6
|
+
def initialize(client)
|
|
7
|
+
@client = client
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
def get(path, query = {}) = @client.request(:get, path, query: compact(query))
|
|
12
|
+
def post(path, body = {}, **options) = @client.request(:post, path, body: compact(body), **options)
|
|
13
|
+
def put(path, body = {}) = @client.request(:put, path, body: compact(body))
|
|
14
|
+
def patch(path, body = {}) = @client.request(:patch, path, body: compact(body))
|
|
15
|
+
def delete(path, body = nil) = @client.request(:delete, path, body: body && compact(body))
|
|
16
|
+
def compact(hash) = hash.to_h.reject { |_, value| value.nil? }
|
|
17
|
+
def id(value) = Client.escape(value)
|
|
18
|
+
|
|
19
|
+
# A customer by our UUID or a Stripe id, or with source: by your id in that source.
|
|
20
|
+
def customer_path(customer, source) = source ? "/api/v1/sources/#{id(source)}/customers/#{id(customer)}" : "/api/v1/customers/#{id(customer)}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# GET /me: the account, its mode (live or a sandbox) and the token's scope.
|
|
24
|
+
class Me < Resource
|
|
25
|
+
def retrieve = get("/api/v1/me")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Your applications and other id sources.
|
|
29
|
+
class Sources < Resource
|
|
30
|
+
def list = get("/api/v1/sources")
|
|
31
|
+
def create(**params) = post("/api/v1/sources", params, idempotent: true) # a second create of the same slug is refused, nothing doubles
|
|
32
|
+
def update(slug, **params) = patch("/api/v1/sources/#{id(slug)}", params)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
class Customers < Resource
|
|
36
|
+
# One page: { "customers" => [...], "pagination" => { "page", "per_page", "next_page" } }.
|
|
37
|
+
def list(**params) = get("/api/v1/customers", params)
|
|
38
|
+
|
|
39
|
+
# Every customer of every page, one by one.
|
|
40
|
+
def each(**params)
|
|
41
|
+
return enum_for(:each, **params) unless block_given?
|
|
42
|
+
|
|
43
|
+
page = params.fetch(:page, 1)
|
|
44
|
+
while page
|
|
45
|
+
answer = list(**params, page:)
|
|
46
|
+
answer.fetch("customers").each { |customer| yield customer }
|
|
47
|
+
page = answer.dig("pagination", "next_page")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# By our UUID or a Stripe id; with source: by your id in that source.
|
|
52
|
+
def retrieve(customer, source: nil) = get(customer_path(customer, source))
|
|
53
|
+
# Creates (201) or updates (200) the customer of your id in your source: the call your app makes for each org.
|
|
54
|
+
def upsert(source, external_id, **params) = put(customer_path(external_id, source), params)
|
|
55
|
+
# Profile fields of a customer by our UUID.
|
|
56
|
+
def update(customer, **params) = patch(customer_path(customer, nil), params)
|
|
57
|
+
# A customer that holds nothing but ids (otherwise 422 not_empty).
|
|
58
|
+
def delete(customer) = super(customer_path(customer, nil))
|
|
59
|
+
# Another id of yours for the same customer (a second workspace).
|
|
60
|
+
def add_id(customer, source:, external_id:) = post("/api/v1/customers/#{id(customer)}/external-references", { source:, external_id: }, idempotent: true)
|
|
61
|
+
def remove_id(customer, value) = @client.request(:delete, "/api/v1/customers/#{id(customer)}/ids/#{id(value)}")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The people of a customer, created or updated by email or external_id.
|
|
65
|
+
class Contacts < Resource
|
|
66
|
+
def create(customer, source: nil, **params) = post("#{customer_path(customer, source)}/contacts", params, idempotent: true)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Usage over time: what your app counts per customer and day.
|
|
70
|
+
class Usage < Resource
|
|
71
|
+
def retrieve(customer, **params) = get("#{customer_path(customer, nil)}/usage", params)
|
|
72
|
+
# Some days of one id: days: { "2026-10-08" => { "ads_launched" => 12 } }.
|
|
73
|
+
def report(source, external_id, days:) = put("#{customer_path(external_id, source)}/usage", { days: })
|
|
74
|
+
# The whole day of a source, every id at once: customers: { "org_1" => { "ads_launched" => 12 } }.
|
|
75
|
+
def report_day(source, date, **params) = put("/api/v1/sources/#{id(source)}/usage/#{id(date)}", params)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class UsageMetrics < Resource
|
|
79
|
+
def list = get("/api/v1/usage-metrics")
|
|
80
|
+
def update(key, **params) = patch("/api/v1/usage-metrics/#{id(key)}", params)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
class Attributes < Resource
|
|
84
|
+
def list = get("/api/v1/attributes")
|
|
85
|
+
def update(key, **params) = patch("/api/v1/attributes/#{id(key)}", params)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Notes and logged calls. An external_id makes a create safe to send again.
|
|
89
|
+
class Notes < Resource
|
|
90
|
+
def list(customer, **params) = get("#{customer_path(customer, nil)}/notes", params)
|
|
91
|
+
def create(customer, **params) = post("#{customer_path(customer, nil)}/notes", params, idempotent: params.key?(:external_id))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
class Tasks < Resource
|
|
95
|
+
# A customer's tasks, or with no customer the account's.
|
|
96
|
+
def list(customer = nil, **params) = get(customer ? "#{customer_path(customer, nil)}/tasks" : "/api/v1/tasks", params)
|
|
97
|
+
def create(customer, **params) = post("#{customer_path(customer, nil)}/tasks", params, idempotent: params.key?(:external_id))
|
|
98
|
+
# By our id or your external_id; completed: true ticks it off.
|
|
99
|
+
def update(task, **params) = patch("/api/v1/tasks/#{id(task)}", params)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
class Research < Resource
|
|
103
|
+
def create(customer, **params) = post("#{customer_path(customer, nil)}/research", params)
|
|
104
|
+
def retrieve(customer) = get("#{customer_path(customer, nil)}/research")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
class Conversations < Resource
|
|
108
|
+
def list(customer, **params) = get("#{customer_path(customer, nil)}/conversations", params)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
class Deals < Resource
|
|
112
|
+
def list(**params) = get("/api/v1/deals", params)
|
|
113
|
+
def retrieve(deal) = get("/api/v1/deals/#{id(deal)}")
|
|
114
|
+
def create(**params) = post("/api/v1/deals", params, idempotent: params.key?(:external_id))
|
|
115
|
+
def update(deal, **params) = patch("/api/v1/deals/#{id(deal)}", params)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
class Pipelines < Resource
|
|
119
|
+
def list = get("/api/v1/pipelines")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Sends an email at once: not retried by itself (a second call is a second email).
|
|
123
|
+
class Emails < Resource
|
|
124
|
+
def list(customer, **params) = get("#{customer_path(customer, nil)}/emails", params)
|
|
125
|
+
def create(customer, **params) = post("#{customer_path(customer, nil)}/emails", params)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
class EmailTemplates < Resource
|
|
129
|
+
def list = get("/api/v1/email_templates")
|
|
130
|
+
def retrieve(template) = get("/api/v1/email_templates/#{id(template)}")
|
|
131
|
+
def create(**params) = post("/api/v1/email_templates", params)
|
|
132
|
+
def update(template, **params) = patch("/api/v1/email_templates/#{id(template)}", params)
|
|
133
|
+
def delete(template) = super("/api/v1/email_templates/#{id(template)}")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
class Sequences < Resource
|
|
137
|
+
def list = get("/api/v1/sequences")
|
|
138
|
+
def retrieve(sequence) = get("/api/v1/sequences/#{id(sequence)}")
|
|
139
|
+
def create(**params) = post("/api/v1/sequences", params)
|
|
140
|
+
def update(sequence, **params) = patch("/api/v1/sequences/#{id(sequence)}", params)
|
|
141
|
+
def delete(sequence) = super("/api/v1/sequences/#{id(sequence)}")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
class Enrollments < Resource
|
|
145
|
+
def list(customer) = get("#{customer_path(customer, nil)}/enrollments")
|
|
146
|
+
def create(customer, **params) = post("#{customer_path(customer, nil)}/enrollments", params)
|
|
147
|
+
def delete(enrollment) = super("/api/v1/enrollments/#{id(enrollment)}")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
class Mrr < Resource
|
|
151
|
+
def retrieve(**params) = get("/api/v1/mrr", params)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
class Movements < Resource
|
|
155
|
+
def list(**params) = get("/api/v1/movements", params)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
class Restatements < Resource
|
|
159
|
+
def list(**params) = get("/api/v1/restatements", params)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
class Reconciliation < Resource
|
|
163
|
+
def retrieve(**params) = get("/api/v1/reconciliation", params)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
class Segments < Resource
|
|
167
|
+
def list(**params) = get("/api/v1/segments", params)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
class Lifecycle < Resource
|
|
171
|
+
def retrieve(**params) = get("/api/v1/lifecycle", params)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Your plan names for Stripe products.
|
|
175
|
+
class Plans < Resource
|
|
176
|
+
def list = get("/api/v1/plans")
|
|
177
|
+
def history(**params) = get("/api/v1/plans/history", params)
|
|
178
|
+
def add_products(name, product_ids:) = put("/api/v1/plans/#{id(name)}", { product_ids: })
|
|
179
|
+
def rename(name, to:) = patch("/api/v1/plans/#{id(name)}", { name: to })
|
|
180
|
+
# Dissolves the plan, or with product_id: takes that one product out.
|
|
181
|
+
def delete(name, product_id: nil) = super("/api/v1/plans/#{id(name)}", product_id ? { product_id: } : nil)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
class AtRisk < Resource
|
|
185
|
+
def list(**params) = get("/api/v1/at-risk", params)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Your own at-risk rules (signals).
|
|
189
|
+
class RiskRules < Resource
|
|
190
|
+
def retrieve = get("/api/v1/risk-rules")
|
|
191
|
+
def create(**params) = post("/api/v1/risk-rules/signals", params)
|
|
192
|
+
def update(signal, **params) = patch("/api/v1/risk-rules/signals/#{id(signal)}", params)
|
|
193
|
+
def delete(signal) = super("/api/v1/risk-rules/signals/#{id(signal)}")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# A rule that does not apply to one customer, with a reason.
|
|
197
|
+
class RiskExceptions < Resource
|
|
198
|
+
def list(**params) = get("/api/v1/risk-exceptions", params)
|
|
199
|
+
def set(customer, signal, **params) = put("#{customer_path(customer, nil)}/risk-exceptions/#{id(signal)}", params)
|
|
200
|
+
def delete(customer, signal) = super("#{customer_path(customer, nil)}/risk-exceptions/#{id(signal)}")
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
class Events < Resource
|
|
204
|
+
def list(**params) = get("/api/v1/events", params)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
class Automations < Resource
|
|
208
|
+
def list = get("/api/v1/automations")
|
|
209
|
+
def retrieve(automation) = get("/api/v1/automations/#{id(automation)}")
|
|
210
|
+
def create(**params) = post("/api/v1/automations", params)
|
|
211
|
+
def update(automation, **params) = patch("/api/v1/automations/#{id(automation)}", params)
|
|
212
|
+
def delete(automation) = super("/api/v1/automations/#{id(automation)}")
|
|
213
|
+
def runs(automation, **params) = get("/api/v1/automations/#{id(automation)}/runs", params)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
class Log < Resource
|
|
217
|
+
def list(**params) = get("/api/v1/log", params)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
class WebhookRequests < Resource
|
|
221
|
+
def list(**params) = get("/api/v1/webhook-requests", params)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# What each org may do in your app, and billing management. app is your application source's slug, org your id.
|
|
225
|
+
class Billing < Resource
|
|
226
|
+
def overrides = @overrides ||= Overrides.new(@client)
|
|
227
|
+
def plan_assignments = @plan_assignments ||= PlanAssignments.new(@client)
|
|
228
|
+
|
|
229
|
+
def answer(app, org) = get("#{base(app)}/orgs/#{id(org)}")
|
|
230
|
+
def catalog(app) = get("#{base(app)}/catalog")
|
|
231
|
+
# Answers and removals newer than a version, 500 a page: { "snapshots", "removed", "next_after_version" }.
|
|
232
|
+
def changes(app, after_version: 0) = get(base(app), { after_version: })
|
|
233
|
+
# events: [{ event_id:, org:, feature:, quantity:, occurred_at:, sequence: }], at most 1,000. Each event_id counts once.
|
|
234
|
+
def report_usage(app, events) = post("#{base(app)}/usage", { events: }, idempotent: true)
|
|
235
|
+
# The current total of a limit you count yourself (seats); replaces the last one.
|
|
236
|
+
def report_total(app, org, feature, total) = put("#{base(app)}/totals/#{id(org)}/#{id(feature)}", { total: })
|
|
237
|
+
def report_fallbacks(app, fallbacks) = post("#{base(app)}/fallbacks", { fallbacks: }, idempotent: true)
|
|
238
|
+
def start_trial(app, org, **params) = post("#{base(app)}/orgs/#{id(org)}/trial", params)
|
|
239
|
+
|
|
240
|
+
# Billing management (switched on by an owner in sumwerk). change: { type: "plan", plan: "business" } and the others.
|
|
241
|
+
def preview(app, org, change) = post("#{base(app)}/orgs/#{id(org)}/preview", { change: }, idempotent: true)
|
|
242
|
+
# Pass an idempotency_key (one per intended change): sent again, it answers with the first change and changes nothing.
|
|
243
|
+
def apply(app, org, change, reason: nil, preview: nil, idempotency_key: nil)
|
|
244
|
+
headers = idempotency_key ? { "Idempotency-Key" => idempotency_key.to_s } : {}
|
|
245
|
+
@client.request(:post, "#{base(app)}/orgs/#{id(org)}/apply", body: compact({ change:, reason:, preview: }), headers:)
|
|
246
|
+
end
|
|
247
|
+
def checkout(app, org, **params) = post("#{base(app)}/orgs/#{id(org)}/checkout", params, idempotent: true)
|
|
248
|
+
def portal(app, org, return_url:) = post("#{base(app)}/orgs/#{id(org)}/portal", { return_url: }, idempotent: true)
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
def base(app) = "/api/v1/billing/#{id(app)}"
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Free exceptions for one customer (by our id, or with source: by your id).
|
|
255
|
+
class Overrides < Resource
|
|
256
|
+
def list(customer, source: nil) = get("#{customer_path(customer, source)}/overrides")
|
|
257
|
+
def create(customer, source: nil, **params) = post("#{customer_path(customer, source)}/overrides", params)
|
|
258
|
+
def revoke(customer, override, source: nil) = delete("#{customer_path(customer, source)}/overrides/#{id(override)}")
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Plans given by hand, with dates and a reason.
|
|
262
|
+
class PlanAssignments < Resource
|
|
263
|
+
def list(customer) = get("#{customer_path(customer, nil)}/plan-assignments")
|
|
264
|
+
def create(customer, **params) = post("#{customer_path(customer, nil)}/plan-assignments", params)
|
|
265
|
+
def revoke(customer, assignment) = delete("#{customer_path(customer, nil)}/plan-assignments/#{id(assignment)}")
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module Sumwerk
|
|
2
|
+
module Billing
|
|
3
|
+
# The answers for one org, from the catalog, the org's snapshot (nil when nothing is known) and the uses this app
|
|
4
|
+
# counted since. Pure: no network, no store, no clock of its own. Every client answers the same
|
|
5
|
+
# (clients/conformance/cases.json).
|
|
6
|
+
class Answer
|
|
7
|
+
Limit = Data.define(:limit, :tolerance, :used, :remaining, :state, :beyond) do
|
|
8
|
+
def unlimited? = limit.nil?
|
|
9
|
+
end
|
|
10
|
+
Use = Data.define(:feature, :quantity, :sequence, :occurred_at)
|
|
11
|
+
# A trial that runs: of which plan, until which day.
|
|
12
|
+
Trial = Data.define(:plan, :ends_on)
|
|
13
|
+
# A cancellation: scheduled (ends_on) or happened (ended_on, the plan kept until a day or for good), and the
|
|
14
|
+
# plan that comes after.
|
|
15
|
+
Cancellation = Data.define(:plan, :ends_on, :ended_on, :until, :then)
|
|
16
|
+
# A plan change waiting for the end of the period (a downgrade): which plan, from which day.
|
|
17
|
+
ScheduledChange = Data.define(:plan, :on)
|
|
18
|
+
|
|
19
|
+
NEAR = 0.8
|
|
20
|
+
|
|
21
|
+
# snapshot: the hash sumwerk published, nil (nothing known), or :unknown_org (sumwerk says it has no such org).
|
|
22
|
+
def initialize(catalog:, snapshot:, pending: [])
|
|
23
|
+
@catalog, @pending = catalog || {}, pending
|
|
24
|
+
@unknown_org = snapshot == :unknown_org
|
|
25
|
+
@snapshot = snapshot.is_a?(Hash) ? snapshot : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# When an answer was a default: "unreachable", "unknown_org" or "unknown_feature", else nil.
|
|
29
|
+
def fallback(key)
|
|
30
|
+
return "unknown_feature" unless catalog_entry(key) || feature(key)
|
|
31
|
+
return "unknown_org" if @unknown_org
|
|
32
|
+
"unreachable" unless @snapshot
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def entitled?(key)
|
|
36
|
+
value = feature(key)
|
|
37
|
+
return default(key, "feature") unless value
|
|
38
|
+
|
|
39
|
+
value["kind"] == "limit" ? allowed?(key) : value["enabled"] == true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def enabled?(key)
|
|
43
|
+
value = feature(key)
|
|
44
|
+
value ? value["enabled"] == true : false # an unknown rollout flag is off
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def limit(key)
|
|
48
|
+
value = feature(key) or return nil
|
|
49
|
+
limit, tolerance, beyond = value["limit"], value["tolerance"].to_i, value["beyond"] || "block"
|
|
50
|
+
used = value["used"].to_i + own_uses(key, value)
|
|
51
|
+
remaining = (limit && beyond == "block") ? [ limit + tolerance - used, 0 ].max : nil
|
|
52
|
+
Limit.new(limit:, tolerance:, used:, remaining:, state: state_of(limit, tolerance, used, beyond), beyond:)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# May the org use quantity more of this limit?
|
|
56
|
+
def allowed?(key, quantity = 1)
|
|
57
|
+
found = limit(key)
|
|
58
|
+
return default(key, "limit") unless found
|
|
59
|
+
return true if found.unlimited? || found.beyond == "notify"
|
|
60
|
+
|
|
61
|
+
found.used + quantity <= found.limit + found.tolerance
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def plan = @snapshot&.dig("plan", "key")
|
|
65
|
+
# Why the org has its plan: "subscription", "assignment", "unmapped" (a running subscription no plan is mapped
|
|
66
|
+
# for: no plan, every feature answers its when_unknown), "trial", "after_cancellation", "default" or "none".
|
|
67
|
+
def plan_source = @snapshot&.dig("plan", "source")
|
|
68
|
+
|
|
69
|
+
def trial
|
|
70
|
+
value = @snapshot&.fetch("trial", nil) or return nil
|
|
71
|
+
Trial.new(plan: value["plan"], ends_on: date(value["ends_on"]))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def scheduled_change
|
|
75
|
+
value = @snapshot&.fetch("scheduled_change", nil) or return nil
|
|
76
|
+
ScheduledChange.new(plan: value["plan"], on: date(value["on"]))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def cancellation
|
|
80
|
+
value = @snapshot&.fetch("cancellation", nil) or return nil
|
|
81
|
+
Cancellation.new(plan: value["plan"], ends_on: date(value["ends_on"]), ended_on: date(value["ended_on"]), until: date(value["until"]), then: value["then"])
|
|
82
|
+
end
|
|
83
|
+
def access = @snapshot&.fetch("access", nil)
|
|
84
|
+
def version = @snapshot&.fetch("version", nil)
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
def date(value) = value && Date.iso8601(value)
|
|
88
|
+
|
|
89
|
+
def feature(key) = @snapshot&.dig("features", key.to_s)
|
|
90
|
+
def catalog_entry(key) = @catalog.dig("features", key.to_s)
|
|
91
|
+
|
|
92
|
+
# Nothing known: the catalog says; a feature the catalog does not know is allowed, never a rollout flag.
|
|
93
|
+
def default(key, kind)
|
|
94
|
+
entry = catalog_entry(key)
|
|
95
|
+
return kind != "rollout" unless entry
|
|
96
|
+
|
|
97
|
+
entry["when_unknown"] == "allow" && !entry["strict"] && entry["kind"] != "rollout"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The app's own uses the snapshot has not counted yet, in the snapshot's period.
|
|
101
|
+
def own_uses(key, value)
|
|
102
|
+
counted = value["counted_through"].to_i
|
|
103
|
+
since = value["period_start"] && Time.iso8601(value["period_start"])
|
|
104
|
+
@pending.select { |use| use.feature == key.to_s && use.sequence > counted && (since.nil? || use.occurred_at >= since) }.sum(&:quantity)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def state_of(limit, tolerance, used, beyond)
|
|
108
|
+
return "within" if limit.nil? || (used <= limit && (limit.zero? || used < limit * NEAR))
|
|
109
|
+
return "near" if used <= limit
|
|
110
|
+
return "over_in_tolerance" if used <= limit + tolerance
|
|
111
|
+
|
|
112
|
+
beyond == "block" ? "blocked" : "over"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
module Sumwerk
|
|
2
|
+
module Billing
|
|
3
|
+
# One app's view of sumwerk entitlements.
|
|
4
|
+
#
|
|
5
|
+
# client = Sumwerk::Billing::Client.new(app: "nordwind", token: ENV["SUMWERK_TOKEN"],
|
|
6
|
+
# store: Sumwerk::Billing::RedisStore.new(Redis.new), webhook_secret: ENV["SUMWERK_WEBHOOK_SECRET"])
|
|
7
|
+
# client.entitled?(org.id, :reports)
|
|
8
|
+
# client.allowed?(org.id, :ad_uploads, 3) # room for three more?
|
|
9
|
+
# client.track(org.id, :ad_uploads, quantity: 3)
|
|
10
|
+
#
|
|
11
|
+
# Answers come from the store; an org the store does not know is asked for at the edge (or sumwerk) once, with a
|
|
12
|
+
# short timeout. When nothing can be known the catalog's default answers, and the client tells sumwerk it had to.
|
|
13
|
+
class Client
|
|
14
|
+
attr_reader :store
|
|
15
|
+
|
|
16
|
+
def initialize(app:, token:, edge_url: nil, api_url: "https://www.sumwerk.com", store: MemoryStore.new, webhook_secret: nil,
|
|
17
|
+
browser_secret: nil, timeout: 0.3, batch: 100, transport: nil, clock: -> { Time.now }, logger: nil)
|
|
18
|
+
@app, @token, @edge_url, @api_url, @store = app.to_s, token, edge_url&.chomp("/"), api_url.chomp("/"), store
|
|
19
|
+
@webhook_secret, @browser_secret, @timeout, @batch, @clock, @logger = webhook_secret, browser_secret, timeout, batch, clock, logger
|
|
20
|
+
@transport = transport || method(:http)
|
|
21
|
+
@buffer, @fallbacks, @mutex = [], Hash.new(0), Mutex.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def answer(org) = Answer.new(catalog:, snapshot: snapshot(org.to_s), pending: @store.uses(org.to_s))
|
|
25
|
+
|
|
26
|
+
def entitled?(org, key) = ask(org, key) { |answer| answer.entitled?(key) }
|
|
27
|
+
def enabled?(org, key) = ask(org, key) { |answer| answer.enabled?(key) }
|
|
28
|
+
def allowed?(org, key, quantity = 1) = ask(org, key) { |answer| answer.allowed?(key, quantity) }
|
|
29
|
+
def limit(org, key) = answer(org).limit(key)
|
|
30
|
+
|
|
31
|
+
# Counts a use at once (the next check sees it) and sends it with the next batch.
|
|
32
|
+
def track(org, key, quantity: 1, event_id: SecureRandom.uuid, at: @clock.call)
|
|
33
|
+
org = org.to_s
|
|
34
|
+
sequence = @store.next_sequence(org)
|
|
35
|
+
@store.add_use(org, Answer::Use.new(feature: key.to_s, quantity:, sequence:, occurred_at: at))
|
|
36
|
+
full = @mutex.synchronize do
|
|
37
|
+
@buffer << { event_id:, org:, feature: key.to_s, quantity:, sequence:, occurred_at: at.utc.iso8601 }
|
|
38
|
+
@buffer.size >= @batch
|
|
39
|
+
end
|
|
40
|
+
flush if full
|
|
41
|
+
sequence
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The current total of a limit the app counts itself (seats).
|
|
45
|
+
# One of the app's customers, under the app's own id, as it signs up or changes: name, email, company, country,
|
|
46
|
+
# owner_email, tags, custom attributes. True when sumwerk took it; a failed call is logged, never raised.
|
|
47
|
+
def upsert_customer(org, attributes)
|
|
48
|
+
status, = request(:put, "#{@api_url}/api/v1/sources/#{@app}/customers/#{escape(org)}", attributes, timeout: 5)
|
|
49
|
+
status.to_i.between?(200, 299)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def report(org, key, total)
|
|
53
|
+
request(:put, "#{@api_url}/api/v1/billing/#{@app}/totals/#{escape(org)}/#{key}", { total: }, timeout: 5)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Sends what was counted and the defaults used since the last flush. What could not be sent stays for the next.
|
|
57
|
+
def flush
|
|
58
|
+
events, fallbacks = @mutex.synchronize { [ @buffer.shift(500), @fallbacks.dup.tap { @fallbacks.clear } ] }
|
|
59
|
+
if events.any?
|
|
60
|
+
status, = post_usage("usage", { events: })
|
|
61
|
+
@mutex.synchronize { @buffer.unshift(*events) } unless status&.between?(200, 299) || status == 422
|
|
62
|
+
end
|
|
63
|
+
if fallbacks.any?
|
|
64
|
+
items = fallbacks.map { |(org, feature, reason), count| { org:, feature:, reason:, count: } }
|
|
65
|
+
status, = post_usage("fallbacks", { fallbacks: items })
|
|
66
|
+
@mutex.synchronize { fallbacks.each { |found, count| @fallbacks[found] += count } } unless status&.between?(200, 299)
|
|
67
|
+
end
|
|
68
|
+
@buffer.size
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Flushes in the background every few seconds; call once at boot.
|
|
72
|
+
def start(every: 5)
|
|
73
|
+
@worker ||= Thread.new { loop { sleep every; safely { flush } } }
|
|
74
|
+
at_exit { safely { flush } }
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A billing.changed webhook: true when the signature held and the snapshots and removals were taken.
|
|
79
|
+
def receive(body, signature)
|
|
80
|
+
return false unless @webhook_secret && Tokens.valid_webhook?(@webhook_secret, body, signature, now: @clock.call)
|
|
81
|
+
|
|
82
|
+
take(JSON.parse(body))
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Asks sumwerk for every answer newer than the last one seen: for apps without a webhook (on localhost) and as a
|
|
87
|
+
# periodic check that nothing was missed.
|
|
88
|
+
def sync
|
|
89
|
+
loop do
|
|
90
|
+
status, body = request(:get, "#{@api_url}/api/v1/billing/#{@app}?after_version=#{@store.read("cursor").to_i}", timeout: 10)
|
|
91
|
+
break unless status == 200
|
|
92
|
+
|
|
93
|
+
page = JSON.parse(body)
|
|
94
|
+
newest = take(page)
|
|
95
|
+
@store.write("cursor", newest) if newest
|
|
96
|
+
break unless page["next_after_version"]
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# For the app's own page: a token its browser uses to read this org's answer (through the app's domain).
|
|
101
|
+
def browser_token(org, ttl: 600)
|
|
102
|
+
raise Error, "browser_secret is not configured" unless @browser_secret
|
|
103
|
+
|
|
104
|
+
Tokens.browser(@browser_secret, account: catalog.fetch("account"), app: @app, org: org.to_s, ttl:, now: @clock.call)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# For a link to sumwerk's customer pages: once, within a minute, for this user of the org (role: "manage" or "view").
|
|
108
|
+
# redirect_to "#{pages_url}/account?token=#{ANSWERS.page_token(org.id, user: current_user.id, email: current_user.email, role: "manage")}"
|
|
109
|
+
def page_token(org, user:, email: nil, role: "manage", ttl: 60)
|
|
110
|
+
raise Error, "browser_secret is not configured" unless @browser_secret
|
|
111
|
+
|
|
112
|
+
Tokens.page(@browser_secret, account: catalog.fetch("account"), app: @app, org: org.to_s, user:, email:, role:, ttl:, now: @clock.call)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def catalog
|
|
116
|
+
cached = @store.read("catalog")&.then { |raw| JSON.parse(raw) }
|
|
117
|
+
return cached if cached && cached["fetched_at"].to_i > @clock.call.to_i - 600
|
|
118
|
+
|
|
119
|
+
status, body = request(:get, "#{base}/catalog")
|
|
120
|
+
return cached || {} unless status == 200
|
|
121
|
+
|
|
122
|
+
JSON.parse(body).merge("fetched_at" => @clock.call.to_i).tap { |fresh| @store.write("catalog", fresh.to_json) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
def ask(org, key)
|
|
127
|
+
found = answer(org)
|
|
128
|
+
reason = found.fallback(key)
|
|
129
|
+
@mutex.synchronize { @fallbacks[[ org.to_s, key.to_s, reason ]] += 1 } if reason
|
|
130
|
+
yield found
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The stored copy, else the edge (or sumwerk) once; :unknown_org when sumwerk has no such org, nil when unreachable.
|
|
134
|
+
def snapshot(org)
|
|
135
|
+
stored = @store.snapshot(org)
|
|
136
|
+
return :unknown_org if stored&.dig("removed")
|
|
137
|
+
|
|
138
|
+
stored || begin
|
|
139
|
+
status, body = request(:get, "#{base}/orgs/#{escape(org)}")
|
|
140
|
+
case status
|
|
141
|
+
when 200 then JSON.parse(body).tap { |fetched| keep(fetched) }
|
|
142
|
+
when 404 then :unknown_org
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Snapshots and removals of a feed page or a webhook; the highest version among them.
|
|
148
|
+
def take(page)
|
|
149
|
+
page["snapshots"].to_a.each { |snapshot| keep(snapshot) }
|
|
150
|
+
# A removal stays as a marker with its version, so that an older answer arriving late does not bring the org back.
|
|
151
|
+
page["removed"].to_a.each { |removal| @store.put_snapshot(removal["org"], { "orgs" => [ removal["org"] ], "removed" => true, "version" => removal["version"] }) }
|
|
152
|
+
(page["snapshots"].to_a + page["removed"].to_a).map { |entry| entry["version"].to_i }.max
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def keep(snapshot)
|
|
156
|
+
snapshot["orgs"].to_a.each do |org|
|
|
157
|
+
next unless @store.put_snapshot(org, snapshot)
|
|
158
|
+
|
|
159
|
+
counted = snapshot["features"].to_h.filter_map { |key, value| [ key, value["counted_through"].to_i ] if value["kind"] == "limit" }.to_h
|
|
160
|
+
@store.forget_counted(org, counted)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def base = @edge_url ? "#{@edge_url}/v1/#{@app}" : "#{@api_url}/api/v1/billing/#{@app}"
|
|
165
|
+
|
|
166
|
+
def post_usage(kind, body)
|
|
167
|
+
url = @edge_url ? "#{@edge_url}/v1/#{@app}/#{kind}" : "#{@api_url}/api/v1/billing/#{@app}/#{kind}"
|
|
168
|
+
request(:post, url, body, timeout: 5)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def request(verb, url, body = nil, timeout: @timeout)
|
|
172
|
+
@transport.call(verb, url, { "Authorization" => "Bearer #{@token}", "Content-Type" => "application/json" }, body&.to_json, timeout)
|
|
173
|
+
rescue StandardError => error
|
|
174
|
+
@logger&.warn("sumwerk billing: #{verb.upcase} #{url}: #{error.class}")
|
|
175
|
+
nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def http(verb, url, headers, body, timeout)
|
|
179
|
+
uri = URI(url)
|
|
180
|
+
request = { get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put }.fetch(verb).new(uri, headers)
|
|
181
|
+
request.body = body if body
|
|
182
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: timeout, read_timeout: timeout) { |connection| connection.request(request) }
|
|
183
|
+
[ response.code.to_i, response.body ]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def escape(value) = URI.encode_www_form_component(value.to_s)
|
|
187
|
+
|
|
188
|
+
def safely
|
|
189
|
+
yield
|
|
190
|
+
rescue StandardError => error
|
|
191
|
+
@logger&.warn("sumwerk billing: #{error.class}: #{error.message}")
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Sumwerk
|
|
2
|
+
module Billing
|
|
3
|
+
# Two small Rack apps to mount in the app:
|
|
4
|
+
# mount Sumwerk::Billing::Webhook.new(client), at: "/sumwerk/billing/webhook"
|
|
5
|
+
# mount Sumwerk::Billing::BrowserProxy.new, at: "/sumwerk/billing"
|
|
6
|
+
class Webhook
|
|
7
|
+
def initialize(client)
|
|
8
|
+
@client = client
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(env)
|
|
12
|
+
request = ::Rack::Request.new(env)
|
|
13
|
+
return [ 405, {}, [] ] unless request.post?
|
|
14
|
+
|
|
15
|
+
@client.receive(request.body.read, env["HTTP_X_SUMWERK_SIGNATURE"]) ? [ 204, {}, [] ] : [ 401, {}, [] ]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Passes the browser's reads (GET …/v1/:app/orgs/:org with a browser token) to sumwerk, from the app's own domain:
|
|
20
|
+
# ad blockers do not block an app's own domain. Only reads, only that path. To sumwerk's API by default, or to an
|
|
21
|
+
# edge when one is given.
|
|
22
|
+
class BrowserProxy
|
|
23
|
+
def initialize(api_url: "https://www.sumwerk.com", edge_url: nil, timeout: 2)
|
|
24
|
+
@api_url, @edge_url, @timeout = api_url.chomp("/"), edge_url&.chomp("/"), timeout
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(env)
|
|
28
|
+
path = env["PATH_INFO"].to_s
|
|
29
|
+
return [ 404, {}, [] ] unless env["REQUEST_METHOD"] == "GET" && path.match?(%r{\A/v1/[a-z][a-z0-9-]*/orgs/[^/]+\z})
|
|
30
|
+
|
|
31
|
+
uri = target(path)
|
|
32
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: @timeout, read_timeout: @timeout) do |http|
|
|
33
|
+
http.request(Net::HTTP::Get.new(uri, "Authorization" => env["HTTP_AUTHORIZATION"].to_s))
|
|
34
|
+
end
|
|
35
|
+
[ response.code.to_i, { "content-type" => "application/json", "cache-control" => "no-store" }, [ response.body.to_s ] ]
|
|
36
|
+
rescue StandardError
|
|
37
|
+
[ 502, { "content-type" => "application/json" }, [ { error: "unreachable" }.to_json ] ]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# /v1/:app/orgs/:org at the edge, /api/v1/billing/:app/orgs/:org at sumwerk's API.
|
|
41
|
+
def target(path) = URI(@edge_url ? "#{@edge_url}#{path}" : "#{@api_url}/api/v1/billing#{path.delete_prefix("/v1")}")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module Sumwerk
|
|
2
|
+
module Billing
|
|
3
|
+
# Where a client keeps snapshots, the catalog and its own uncounted uses. MemoryStore for one process (tests,
|
|
4
|
+
# small apps); RedisStore so every process of the app shares one copy and one count.
|
|
5
|
+
class MemoryStore
|
|
6
|
+
def initialize
|
|
7
|
+
@mutex = Mutex.new
|
|
8
|
+
@snapshots, @pending, @sequences, @values = {}, Hash.new { |hash, key| hash[key] = [] }, Hash.new(0), {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def snapshot(org) = @mutex.synchronize { @snapshots[org] }
|
|
12
|
+
|
|
13
|
+
# Keeps the copy with the higher version; true when this one was newer.
|
|
14
|
+
def put_snapshot(org, snapshot)
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
current = @snapshots[org]
|
|
17
|
+
next false if current && current["version"].to_i >= snapshot["version"].to_i
|
|
18
|
+
|
|
19
|
+
@snapshots[org] = snapshot
|
|
20
|
+
true
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def delete_snapshot(org) = @mutex.synchronize { @snapshots.delete(org) }
|
|
25
|
+
def next_sequence(org) = @mutex.synchronize { @sequences[org] += 1 }
|
|
26
|
+
def add_use(org, use) = @mutex.synchronize { @pending[org] << use }
|
|
27
|
+
def uses(org) = @mutex.synchronize { @pending[org].dup }
|
|
28
|
+
|
|
29
|
+
# Forget the uses the snapshot has counted (per feature: sequence up to counted_through).
|
|
30
|
+
def forget_counted(org, counted_through_by_feature)
|
|
31
|
+
@mutex.synchronize { @pending[org].reject! { |use| use.sequence <= counted_through_by_feature.fetch(use.feature, 0) } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def read(key) = @mutex.synchronize { @values[key] }
|
|
35
|
+
def write(key, value) = @mutex.synchronize { @values[key] = value }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# redis: a redis-rb (or compatible) client the app already has.
|
|
39
|
+
class RedisStore
|
|
40
|
+
PUT_IF_NEWER = <<~LUA.freeze
|
|
41
|
+
local current = redis.call("GET", KEYS[1])
|
|
42
|
+
if current and tonumber(cjson.decode(current)["version"] or 0) >= tonumber(ARGV[2]) then return 0 end
|
|
43
|
+
redis.call("SET", KEYS[1], ARGV[1])
|
|
44
|
+
return 1
|
|
45
|
+
LUA
|
|
46
|
+
|
|
47
|
+
def initialize(redis, namespace: "sumwerk")
|
|
48
|
+
@redis, @namespace = redis, namespace
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def snapshot(org) = @redis.get(key("snap", org))&.then { |raw| JSON.parse(raw) }
|
|
52
|
+
def put_snapshot(org, snapshot) = @redis.eval(PUT_IF_NEWER, keys: [ key("snap", org) ], argv: [ snapshot.to_json, snapshot["version"].to_i ]) == 1
|
|
53
|
+
def delete_snapshot(org) = @redis.del(key("snap", org))
|
|
54
|
+
def next_sequence(org) = @redis.incr(key("seq", org))
|
|
55
|
+
|
|
56
|
+
def add_use(org, use)
|
|
57
|
+
@redis.zadd(key("uses", org), use.sequence, [ use.feature, use.quantity, use.sequence, use.occurred_at.utc.iso8601 ].to_json)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def uses(org)
|
|
61
|
+
@redis.zrange(key("uses", org), 0, -1).map do |raw|
|
|
62
|
+
feature, quantity, sequence, at = JSON.parse(raw)
|
|
63
|
+
Answer::Use.new(feature:, quantity:, sequence:, occurred_at: Time.iso8601(at))
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def forget_counted(org, counted_through_by_feature)
|
|
68
|
+
counted = uses(org).select { |use| use.sequence <= counted_through_by_feature.fetch(use.feature, 0) }
|
|
69
|
+
counted.each { |use| @redis.zrem(key("uses", org), [ use.feature, use.quantity, use.sequence, use.occurred_at.utc.iso8601 ].to_json) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def read(name) = @redis.get(key("value", name))
|
|
73
|
+
def write(name, value) = @redis.set(key("value", name), value)
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
def key(kind, name) = "#{@namespace}:#{kind}:#{name}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# For a Rails app without Redis: its shared cache (Solid Cache, Memcached, …) as the store, so every process sees
|
|
80
|
+
# the same copy. Keeping the newer version is read-then-write here, not one atomic step: two webhooks racing can
|
|
81
|
+
# briefly leave the older copy, and the next sync or webhook puts the newer one back.
|
|
82
|
+
class CacheStore
|
|
83
|
+
def initialize(cache, namespace: "sumwerk", expires_in: 7 * 24 * 3600)
|
|
84
|
+
@cache, @namespace, @expires_in = cache, namespace, expires_in
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def snapshot(org) = @cache.read(key("snap", org))
|
|
88
|
+
|
|
89
|
+
def put_snapshot(org, snapshot)
|
|
90
|
+
current = snapshot(org)
|
|
91
|
+
return false if current && current["version"].to_i >= snapshot["version"].to_i
|
|
92
|
+
|
|
93
|
+
@cache.write(key("snap", org), snapshot, expires_in: @expires_in)
|
|
94
|
+
true
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def delete_snapshot(org) = @cache.delete(key("snap", org))
|
|
98
|
+
def next_sequence(org) = @cache.increment(key("seq", org), 1, expires_in: @expires_in) || raise(Error, "the cache cannot count")
|
|
99
|
+
|
|
100
|
+
def add_use(org, use) = @cache.write(key("uses", org), stored_uses(org) + [ use.to_h ], expires_in: @expires_in)
|
|
101
|
+
def uses(org) = stored_uses(org).map { |raw| Answer::Use.new(**raw.transform_keys(&:to_sym)) }
|
|
102
|
+
|
|
103
|
+
def forget_counted(org, counted_through_by_feature)
|
|
104
|
+
kept = uses(org).reject { |use| use.sequence <= counted_through_by_feature.fetch(use.feature, 0) }
|
|
105
|
+
@cache.write(key("uses", org), kept.map(&:to_h), expires_in: @expires_in)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def read(name) = @cache.read(key("value", name))
|
|
109
|
+
def write(name, value) = @cache.write(key("value", name), value, expires_in: @expires_in)
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
def stored_uses(org) = Array(@cache.read(key("uses", org)))
|
|
113
|
+
def key(kind, name) = "#{@namespace}:#{kind}:#{name}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Sumwerk
|
|
2
|
+
module Billing
|
|
3
|
+
# The two signed things an app handles: webhooks from sumwerk (checked) and tokens for its browser (made).
|
|
4
|
+
module Tokens
|
|
5
|
+
TOLERANCE = 300 # seconds a webhook's timestamp may be off
|
|
6
|
+
|
|
7
|
+
# X-Sumwerk-Signature: t=<unix time>,v1=<HMAC-SHA256 of "<t>.<body>">
|
|
8
|
+
def self.valid_webhook?(secret, body, header, now: Time.now)
|
|
9
|
+
timestamp, signature = header.to_s.scan(/t=(\d+),v1=(\h+)/).first
|
|
10
|
+
return false unless timestamp && (now.to_i - timestamp.to_i).abs <= TOLERANCE
|
|
11
|
+
|
|
12
|
+
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{body}")
|
|
13
|
+
OpenSSL.fixed_length_secure_compare(expected, signature)
|
|
14
|
+
rescue ArgumentError
|
|
15
|
+
false
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# swb_<base64url payload>.<base64url HMAC-SHA256>, for one org and a few minutes; checked by sumwerk's edge.
|
|
19
|
+
def self.browser(secret, account:, app:, org:, ttl: 600, now: Time.now)
|
|
20
|
+
sign(secret, { a: account, p: app, o: org, e: now.to_i + ttl })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ROLES = %w[manage view].freeze
|
|
24
|
+
|
|
25
|
+
# Opens sumwerk's customer pages (account, billing, checkout) for one of the app's users, once, within a minute:
|
|
26
|
+
# make it only for a user who belongs to the org; role "view" sees billing without changing it.
|
|
27
|
+
def self.page(secret, account:, app:, org:, user:, email: nil, role: "manage", ttl: 60, now: Time.now)
|
|
28
|
+
raise ArgumentError, "role is manage or view" unless ROLES.include?(role)
|
|
29
|
+
|
|
30
|
+
sign(secret, { a: account, p: app, o: org, e: now.to_i + ttl, x: "page", j: SecureRandom.hex(12), u: user.to_s, m: email, r: role }.compact)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.sign(secret, claims)
|
|
34
|
+
payload = Base64.urlsafe_encode64(claims.to_json, padding: false)
|
|
35
|
+
"swb_#{payload}.#{Base64.urlsafe_encode64(OpenSSL::HMAC.digest("SHA256", secret, payload), padding: false)}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "openssl"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "time"
|
|
6
|
+
require "base64"
|
|
7
|
+
|
|
8
|
+
module Sumwerk
|
|
9
|
+
# Ask "may this org do this?" without a network call: answers come from a local copy that sumwerk keeps current
|
|
10
|
+
# (webhooks, the edge, a periodic sync), and the app's own uses count at once. See README.md.
|
|
11
|
+
module Billing
|
|
12
|
+
Error = Class.new(StandardError)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
require_relative "billing/answer"
|
|
17
|
+
require_relative "billing/stores"
|
|
18
|
+
require_relative "billing/tokens"
|
|
19
|
+
require_relative "billing/client"
|
|
20
|
+
require_relative "billing/rack"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module Sumwerk
|
|
2
|
+
# The whole sumwerk API, one object per area, like Stripe's:
|
|
3
|
+
#
|
|
4
|
+
# sumwerk = Sumwerk::Client.new(token: ENV.fetch("SUMWERK_TOKEN"))
|
|
5
|
+
# sumwerk.customers.list(status: "paying")
|
|
6
|
+
# sumwerk.customers.upsert("nordwind", org.id, name: org.name, stripe_customer_ids: [ org.stripe_id ])
|
|
7
|
+
# sumwerk.notes.create(customer_id, body: "Called about the renewal")
|
|
8
|
+
# sumwerk.billing.apply("nordwind", org.id, { type: "plan", plan: "business" }, idempotency_key: request_id)
|
|
9
|
+
# sumwerk.request(:get, "/api/v1/me") # anything the client has no method for yet
|
|
10
|
+
#
|
|
11
|
+
# Each call answers the API's JSON as a Hash with string keys, or raises a Sumwerk::Error. Timeouts, lost
|
|
12
|
+
# connections, 5xx and 429 are retried with growing waits (429: the seconds in Retry-After) for every call that is
|
|
13
|
+
# safe to send twice: reads, PUT, DELETE, and POSTs the API makes idempotent (usage events by event_id, apply with
|
|
14
|
+
# an idempotency key). For an org's billing answers without a call per check, see Sumwerk::Billing::Client.
|
|
15
|
+
class Client
|
|
16
|
+
attr_reader :api_url
|
|
17
|
+
|
|
18
|
+
def initialize(token:, api_url: "https://www.sumwerk.com", timeout: 30, max_retries: 2, transport: nil, sleeper: ->(seconds) { sleep(seconds) })
|
|
19
|
+
@token, @api_url, @timeout, @max_retries, @sleeper = token, api_url.chomp("/"), timeout, max_retries, sleeper
|
|
20
|
+
@transport = transport || method(:http)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def me = @me ||= Api::Me.new(self)
|
|
24
|
+
def sources = @sources ||= Api::Sources.new(self)
|
|
25
|
+
def customers = @customers ||= Api::Customers.new(self)
|
|
26
|
+
def contacts = @contacts ||= Api::Contacts.new(self)
|
|
27
|
+
def usage = @usage ||= Api::Usage.new(self)
|
|
28
|
+
def usage_metrics = @usage_metrics ||= Api::UsageMetrics.new(self)
|
|
29
|
+
def attributes = @attributes ||= Api::Attributes.new(self)
|
|
30
|
+
def notes = @notes ||= Api::Notes.new(self)
|
|
31
|
+
def tasks = @tasks ||= Api::Tasks.new(self)
|
|
32
|
+
def research = @research ||= Api::Research.new(self)
|
|
33
|
+
def conversations = @conversations ||= Api::Conversations.new(self)
|
|
34
|
+
def deals = @deals ||= Api::Deals.new(self)
|
|
35
|
+
def pipelines = @pipelines ||= Api::Pipelines.new(self)
|
|
36
|
+
def emails = @emails ||= Api::Emails.new(self)
|
|
37
|
+
def email_templates = @email_templates ||= Api::EmailTemplates.new(self)
|
|
38
|
+
def sequences = @sequences ||= Api::Sequences.new(self)
|
|
39
|
+
def enrollments = @enrollments ||= Api::Enrollments.new(self)
|
|
40
|
+
def mrr = @mrr ||= Api::Mrr.new(self)
|
|
41
|
+
def movements = @movements ||= Api::Movements.new(self)
|
|
42
|
+
def restatements = @restatements ||= Api::Restatements.new(self)
|
|
43
|
+
def reconciliation = @reconciliation ||= Api::Reconciliation.new(self)
|
|
44
|
+
def segments = @segments ||= Api::Segments.new(self)
|
|
45
|
+
def lifecycle = @lifecycle ||= Api::Lifecycle.new(self)
|
|
46
|
+
def plans = @plans ||= Api::Plans.new(self)
|
|
47
|
+
def at_risk = @at_risk ||= Api::AtRisk.new(self)
|
|
48
|
+
def risk_rules = @risk_rules ||= Api::RiskRules.new(self)
|
|
49
|
+
def risk_exceptions = @risk_exceptions ||= Api::RiskExceptions.new(self)
|
|
50
|
+
def events = @events ||= Api::Events.new(self)
|
|
51
|
+
def automations = @automations ||= Api::Automations.new(self)
|
|
52
|
+
def log = @log ||= Api::Log.new(self)
|
|
53
|
+
def webhook_requests = @webhook_requests ||= Api::WebhookRequests.new(self)
|
|
54
|
+
def billing = @billing ||= Api::Billing.new(self)
|
|
55
|
+
|
|
56
|
+
# One call: path from /api/v1 on, query (a Hash) and body (a Hash, sent as JSON). idempotent: true marks a POST as safe
|
|
57
|
+
# to send twice (it is retried like a read). Answers the parsed JSON (nil for 204).
|
|
58
|
+
def request(verb, path, query: nil, body: nil, headers: {}, idempotent: nil)
|
|
59
|
+
verb = verb.to_sym
|
|
60
|
+
url = "#{@api_url}#{path}"
|
|
61
|
+
url += "?#{encode(query)}" if query && !query.empty?
|
|
62
|
+
safe = idempotent.nil? ? (verb != :post || headers.key?("Idempotency-Key")) : idempotent
|
|
63
|
+
headers = { "Authorization" => "Bearer #{@token}", "Accept" => "application/json", "User-Agent" => "sumwerk-ruby/#{VERSION}" }
|
|
64
|
+
.merge(body ? { "Content-Type" => "application/json" } : {}).merge(headers)
|
|
65
|
+
attempt = 0
|
|
66
|
+
begin
|
|
67
|
+
attempt += 1
|
|
68
|
+
status, raw, answer_headers = call(verb, url, headers, body&.to_json)
|
|
69
|
+
parsed = parse(raw)
|
|
70
|
+
return parsed if status.between?(200, 299)
|
|
71
|
+
|
|
72
|
+
raise Error.for(status, parsed, answer_headers.to_h.transform_keys { |key| key.to_s.downcase })
|
|
73
|
+
rescue RateLimitError, APIError, ConnectionError => error
|
|
74
|
+
retriable = safe || error.is_a?(RateLimitError) # a 429 counted nothing
|
|
75
|
+
raise unless retriable && attempt <= @max_retries
|
|
76
|
+
|
|
77
|
+
@sleeper.call(error.is_a?(RateLimitError) && error.retry_after.positive? ? [ error.retry_after, 60 ].min : 0.5 * (2**(attempt - 1)))
|
|
78
|
+
retry
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Escapes one id for a path: an org id may hold any character.
|
|
83
|
+
def self.escape(value) = URI.encode_www_form_component(value.to_s).gsub("+", "%20")
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
def call(verb, url, headers, body)
|
|
87
|
+
@transport.call(verb, url, headers, body, @timeout)
|
|
88
|
+
rescue Timeout::Error, SystemCallError, SocketError, IOError, OpenSSL::SSL::SSLError => error
|
|
89
|
+
raise ConnectionError.new("#{verb.upcase} #{url}: #{error.class}: #{error.message}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse(raw)
|
|
93
|
+
return nil if raw.nil? || raw.strip.empty?
|
|
94
|
+
|
|
95
|
+
JSON.parse(raw)
|
|
96
|
+
rescue JSON::ParserError
|
|
97
|
+
{ "error" => "invalid_answer", "message" => raw.to_s[0, 200] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def encode(query)
|
|
101
|
+
query.flat_map { |key, value| Array(value).map { |each| [ value.is_a?(Array) ? "#{key}[]" : key.to_s, each.to_s ] } }.then { |pairs| URI.encode_www_form(pairs) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def http(verb, url, headers, body, timeout)
|
|
105
|
+
uri = URI(url)
|
|
106
|
+
klass = { get: Net::HTTP::Get, post: Net::HTTP::Post, put: Net::HTTP::Put, patch: Net::HTTP::Patch, delete: Net::HTTP::Delete }.fetch(verb)
|
|
107
|
+
request = klass.new(uri, headers)
|
|
108
|
+
request.body = body if body
|
|
109
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: timeout, read_timeout: timeout) { |connection| connection.request(request) }
|
|
110
|
+
[ response.code.to_i, response.body, response.each_header.to_h ]
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Sumwerk
|
|
2
|
+
# Everything the API answers with an error, with its status, the API's `error` code, `message` and the whole body.
|
|
3
|
+
class Error < StandardError
|
|
4
|
+
attr_reader :status, :code, :body, :headers
|
|
5
|
+
|
|
6
|
+
def initialize(message = nil, status: nil, code: nil, body: nil, headers: {})
|
|
7
|
+
super(message || code || "sumwerk answered #{status}")
|
|
8
|
+
@status, @code, @body, @headers = status, code, body, headers
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# The error class for an answer: AuthenticationError for 401, NotFoundError for 404 and so on.
|
|
12
|
+
def self.for(status, body, headers = {})
|
|
13
|
+
parsed = body.is_a?(Hash) ? body : {}
|
|
14
|
+
klass = { 400 => InvalidRequestError, 401 => AuthenticationError, 403 => PermissionError, 404 => NotFoundError, 409 => ConflictError,
|
|
15
|
+
410 => GoneError, 422 => InvalidRequestError, 429 => RateLimitError }.fetch(status) { status >= 500 ? APIError : Error }
|
|
16
|
+
klass.new(parsed["message"] || parsed["error"], status:, code: parsed["error"], body: parsed, headers:)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
InvalidRequestError = Class.new(Error) # 400, 422: the request cannot be right; `message` says why
|
|
21
|
+
AuthenticationError = Class.new(Error) # 401: no token, or an unknown or revoked one
|
|
22
|
+
PermissionError = Class.new(Error) # 403: a read-only token wrote, or billing management is off
|
|
23
|
+
NotFoundError = Class.new(Error) # 404
|
|
24
|
+
ConflictError = Class.new(Error) # 409: the id belongs to another customer
|
|
25
|
+
GoneError = Class.new(Error) # 410: a call that no longer exists
|
|
26
|
+
APIError = Class.new(Error) # 5xx: on sumwerk's side; retried before it is raised
|
|
27
|
+
ConnectionError = Class.new(Error) # no answer: timeout, refused, DNS; retried before it is raised
|
|
28
|
+
|
|
29
|
+
# 429: more than 600 requests a minute with this token. `retry_after` is the seconds to wait.
|
|
30
|
+
class RateLimitError < Error
|
|
31
|
+
def retry_after = headers["retry-after"].to_i
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/sumwerk.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
# The sumwerk API for Ruby: Sumwerk::Client for every call (customers, usage, notes, revenue, billing), and
|
|
6
|
+
# Sumwerk::Billing for answering "may this org do this?" from a local copy. See README.md.
|
|
7
|
+
module Sumwerk
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
require_relative "sumwerk/version"
|
|
11
|
+
require_relative "sumwerk/errors"
|
|
12
|
+
require_relative "sumwerk/client"
|
|
13
|
+
require_relative "sumwerk/api"
|
|
14
|
+
require_relative "sumwerk/billing"
|
metadata
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sumwerk
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kitchn Venture GmbH
|
|
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'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: Every call of the sumwerk API (customers, usage, notes, tasks, revenue,
|
|
27
|
+
billing) with errors and retries, and billing answers (features, limits, rollout
|
|
28
|
+
flags) from a local copy kept current by sumwerk.
|
|
29
|
+
email:
|
|
30
|
+
- hello@sumwerk.com
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- lib/sumwerk.rb
|
|
38
|
+
- lib/sumwerk/api.rb
|
|
39
|
+
- lib/sumwerk/billing.rb
|
|
40
|
+
- lib/sumwerk/billing/answer.rb
|
|
41
|
+
- lib/sumwerk/billing/client.rb
|
|
42
|
+
- lib/sumwerk/billing/rack.rb
|
|
43
|
+
- lib/sumwerk/billing/stores.rb
|
|
44
|
+
- lib/sumwerk/billing/tokens.rb
|
|
45
|
+
- lib/sumwerk/client.rb
|
|
46
|
+
- lib/sumwerk/errors.rb
|
|
47
|
+
- lib/sumwerk/version.rb
|
|
48
|
+
homepage: https://www.sumwerk.com
|
|
49
|
+
licenses:
|
|
50
|
+
- MIT
|
|
51
|
+
metadata:
|
|
52
|
+
documentation_uri: https://www.sumwerk.com/docs/api
|
|
53
|
+
changelog_uri: https://www.sumwerk.com/changelog
|
|
54
|
+
rubygems_mfa_required: 'true'
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '3.2'
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 4.0.3
|
|
70
|
+
specification_version: 4
|
|
71
|
+
summary: The sumwerk API for Ruby
|
|
72
|
+
test_files: []
|