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 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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module Sumwerk
2
+ VERSION = "0.2.0".freeze
3
+ 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: []