marginfuse 0.1.0 → 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 +4 -4
- data/CHANGELOG.md +30 -0
- data/README.md +31 -0
- data/lib/marginfuse/client.rb +63 -8
- data/lib/marginfuse/types.rb +17 -0
- data/lib/marginfuse/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ed4b052cde767f31778131e10df10f850d206c4db7f2215040de98faec393af0
|
|
4
|
+
data.tar.gz: 428cb94a4937c73876d998db48026aea9b2f8b69730b1149bd5e3947051a19c9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e7d968bfd7aa9d82acaa132b72402396cca466b6940d825da506326b7a508376dcbacf203cc16529a7f8d98c0a52f9e3b6d04ba3341820ca1eea8541b0443bb5
|
|
7
|
+
data.tar.gz: 2cd18e81de9e717d56607177da46c46c758bb46a443c9bebe2298e16ad68873156bd1e5123d3b09c24460f68823d53ec7064d942be43da0938d52e07fa2b1a5e
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,36 @@ All notable changes to this project are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
|
|
5
5
|
follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.2.0]
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- `identify`: tell MarginFuse who a customer is and which plan they are on.
|
|
12
|
+
|
|
13
|
+
MarginFuse can now compute margin without a revenue source connected, from
|
|
14
|
+
plans you declare in Settings and a plan assigned per customer. This call is
|
|
15
|
+
how your application assigns that plan itself.
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
mf.identify(customer_id: "user_8x2m91", plan: "pro", name: "Acme Studio")
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`plan` is the key of a plan declared in MarginFuse, not a Stripe price id.
|
|
22
|
+
Safe to call on every sign-in: sending the plan the customer is already on
|
|
23
|
+
changes nothing. `period_start` backdates the cycle, `clear_plan` ends it.
|
|
24
|
+
|
|
25
|
+
Unlike `track`, this one reports failure instead of failing quietly. A wrong
|
|
26
|
+
plan is a wrong margin, and there is no safe default for "I could not record
|
|
27
|
+
what this customer pays". Check `result.ok?`; `on_error` is called too. It
|
|
28
|
+
still never raises into your code.
|
|
29
|
+
|
|
30
|
+
- `plan` on `track`, `guard` and `decide`, so a plan can ride along with usage
|
|
31
|
+
rather than needing its own call. There it is a hint: a key that does not
|
|
32
|
+
resolve is ignored rather than failing your event, because usage must never
|
|
33
|
+
be lost to a plan note.
|
|
34
|
+
|
|
35
|
+
Both are additive. Existing code keeps working unchanged.
|
|
36
|
+
|
|
7
37
|
## [0.1.0]
|
|
8
38
|
|
|
9
39
|
First release. Ruby 3.2+, zero dependencies, standard library only.
|
data/README.md
CHANGED
|
@@ -96,6 +96,37 @@ There is no failure a caller should branch on. A decision that times out or
|
|
|
96
96
|
errors is an *allow* with `degraded?` true, because MarginFuse being unreachable
|
|
97
97
|
must never become your outage. Transport failures go to `on_error`.
|
|
98
98
|
|
|
99
|
+
## Tell MarginFuse what a customer pays
|
|
100
|
+
|
|
101
|
+
Margin needs a revenue side. With Stripe connected it comes from there. Without
|
|
102
|
+
one, you declare your plans in MarginFuse and say which plan each customer is
|
|
103
|
+
on:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
result = mf.identify(
|
|
107
|
+
customer_id: "user_8x2m91",
|
|
108
|
+
plan: "pro", # the key of a plan you declared in Settings
|
|
109
|
+
name: "Acme Studio",
|
|
110
|
+
metadata: { "tier" => "legacy" } # labels segment policies can match on
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
warn "MarginFuse identify: #{result.error}" unless result.ok?
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Safe to call on every sign-in: sending the plan the customer is already on
|
|
117
|
+
changes nothing. Sending a different one ends the current cycle and prorates
|
|
118
|
+
what accrued. `period_start` backdates the cycle for a customer who has been
|
|
119
|
+
paying since an earlier date; `clear_plan: true` takes them off plans.
|
|
120
|
+
|
|
121
|
+
This is the one call that does not fail open. `track` retries later and
|
|
122
|
+
`decide` allows, because both have a safe default; "I could not record what
|
|
123
|
+
this customer pays" has none, and a wrong plan is a wrong margin. So it reports
|
|
124
|
+
the failure to you instead of swallowing it. It still never raises.
|
|
125
|
+
|
|
126
|
+
`track`, `guard` and `decide` also accept a `plan`, so it can ride along with
|
|
127
|
+
usage rather than needing its own call. There it is a hint: a key that does not
|
|
128
|
+
resolve is ignored rather than failing your event.
|
|
129
|
+
|
|
99
130
|
## OpenRouter and other gateways
|
|
100
131
|
|
|
101
132
|
Gateways report the real cost of every call. Forward it and your figures are
|
data/lib/marginfuse/client.rb
CHANGED
|
@@ -63,9 +63,10 @@ module MarginFuse
|
|
|
63
63
|
# outage.
|
|
64
64
|
#
|
|
65
65
|
# @return [Decision]
|
|
66
|
-
def decide(customer_id:, provider:, model:, feature: nil, expected_usage: nil)
|
|
66
|
+
def decide(customer_id:, provider:, model:, plan: nil, feature: nil, expected_usage: nil)
|
|
67
67
|
body = {
|
|
68
68
|
"customerId" => customer_id,
|
|
69
|
+
"plan" => plan,
|
|
69
70
|
"feature" => feature,
|
|
70
71
|
"provider" => provider,
|
|
71
72
|
"model" => model,
|
|
@@ -106,12 +107,14 @@ module MarginFuse
|
|
|
106
107
|
# Call {#flush} before the process exits, or the last events go with it.
|
|
107
108
|
#
|
|
108
109
|
# @return [void]
|
|
109
|
-
def track(customer_id:, provider:, model:, usage: nil,
|
|
110
|
-
|
|
111
|
-
|
|
110
|
+
def track(customer_id:, provider:, model:, usage: nil, plan: nil, feature: nil,
|
|
111
|
+
requested_model: nil, cost_usd: nil, event_id: nil, occurred_at: nil,
|
|
112
|
+
outcome: :success, decision_id: nil, retry_of_event_id: nil,
|
|
113
|
+
corrects_event_id: nil)
|
|
112
114
|
event = {
|
|
113
115
|
"eventId" => event_id || "evt_#{SecureRandom.uuid}",
|
|
114
116
|
"customerId" => customer_id,
|
|
117
|
+
"plan" => plan,
|
|
115
118
|
"feature" => feature,
|
|
116
119
|
"provider" => provider,
|
|
117
120
|
"model" => model,
|
|
@@ -128,6 +131,58 @@ module MarginFuse
|
|
|
128
131
|
background { send_event(event) }
|
|
129
132
|
end
|
|
130
133
|
|
|
134
|
+
# Tells MarginFuse who a customer is and what plan they are on.
|
|
135
|
+
#
|
|
136
|
+
# +plan+ is the key of a plan you declared in MarginFuse Settings, not a
|
|
137
|
+
# Stripe price id. MarginFuse derives that customer's revenue from the
|
|
138
|
+
# plan's price for every cycle, which is what makes margin per customer and
|
|
139
|
+
# margin policies work with no revenue source connected. Those figures are
|
|
140
|
+
# labeled as a declared price wherever they appear, because nobody
|
|
141
|
+
# confirmed collection.
|
|
142
|
+
#
|
|
143
|
+
# Safe to call on every sign-in: sending the plan the customer is already
|
|
144
|
+
# on changes nothing. Sending a different one ends the current cycle at
|
|
145
|
+
# that moment and prorates what accrued. +period_start+ backdates the cycle;
|
|
146
|
+
# +clear_plan+ takes the customer off plans entirely.
|
|
147
|
+
#
|
|
148
|
+
# Unlike {#track}, this waits and reports failure. track has a safe default
|
|
149
|
+
# - retry later - and "I could not record what this customer pays" has
|
|
150
|
+
# none, because a wrong plan is a wrong margin. It still never raises:
|
|
151
|
+
# check +result.ok?+, and +on_error+ is called too.
|
|
152
|
+
#
|
|
153
|
+
# @return [IdentifyResult]
|
|
154
|
+
def identify(customer_id:, plan: nil, clear_plan: false, period_start: nil,
|
|
155
|
+
name: nil, email: nil, metadata: nil)
|
|
156
|
+
body = {
|
|
157
|
+
"customerId" => customer_id,
|
|
158
|
+
"plan" => plan,
|
|
159
|
+
"clearPlan" => (true if clear_plan),
|
|
160
|
+
"periodStart" => period_start&.utc&.iso8601(6),
|
|
161
|
+
"name" => name,
|
|
162
|
+
"email" => email,
|
|
163
|
+
"metadata" => metadata
|
|
164
|
+
}.compact
|
|
165
|
+
|
|
166
|
+
response = post("/v1/identify", body, 5.0)
|
|
167
|
+
unless (200..299).cover?(response.code.to_i)
|
|
168
|
+
error = RuntimeError.new("identify: HTTP #{response.code}")
|
|
169
|
+
report(error, "identify")
|
|
170
|
+
return IdentifyResult.new(ok: false, error: error.message)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
parsed = JSON.parse(response.body)
|
|
174
|
+
IdentifyResult.new(
|
|
175
|
+
ok: true,
|
|
176
|
+
customer_id: parsed["customerId"],
|
|
177
|
+
plan: parsed["plan"],
|
|
178
|
+
period_start: parsed["periodStart"],
|
|
179
|
+
period_end: parsed["periodEnd"]
|
|
180
|
+
)
|
|
181
|
+
rescue StandardError => e
|
|
182
|
+
report(e, "identify")
|
|
183
|
+
IdentifyResult.new(ok: false, error: e.message)
|
|
184
|
+
end
|
|
185
|
+
|
|
131
186
|
# {#track} for jobs and scripts that must not exit early.
|
|
132
187
|
# @return [void]
|
|
133
188
|
def track_and_wait(**params)
|
|
@@ -164,9 +219,9 @@ module MarginFuse
|
|
|
164
219
|
# provider may still have charged for it.
|
|
165
220
|
#
|
|
166
221
|
# @return [GuardOutcome]
|
|
167
|
-
def guard(customer_id:, provider:, model:, feature: nil, expected_usage: nil)
|
|
222
|
+
def guard(customer_id:, provider:, model:, plan: nil, feature: nil, expected_usage: nil)
|
|
168
223
|
decision = decide(customer_id: customer_id, provider: provider, model: model,
|
|
169
|
-
feature: feature, expected_usage: expected_usage)
|
|
224
|
+
plan: plan, feature: feature, expected_usage: expected_usage)
|
|
170
225
|
|
|
171
226
|
# Enforcement depends on the ACTION alone. A missing id costs an
|
|
172
227
|
# acknowledgment; it must never turn a block into a provider call.
|
|
@@ -184,7 +239,7 @@ module MarginFuse
|
|
|
184
239
|
begin
|
|
185
240
|
call = yield(decision)
|
|
186
241
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
187
|
-
track(customer_id: customer_id, feature: feature, provider: provider,
|
|
242
|
+
track(customer_id: customer_id, plan: plan, feature: feature, provider: provider,
|
|
188
243
|
model: model_used, requested_model: model, usage: {},
|
|
189
244
|
outcome: :provider_error, decision_id: decision.id)
|
|
190
245
|
acknowledge(decision.id, :proceeded_as_requested) if decision.id
|
|
@@ -192,7 +247,7 @@ module MarginFuse
|
|
|
192
247
|
end
|
|
193
248
|
|
|
194
249
|
call ||= {}
|
|
195
|
-
track(customer_id: customer_id, feature: feature, provider: provider,
|
|
250
|
+
track(customer_id: customer_id, plan: plan, feature: feature, provider: provider,
|
|
196
251
|
model: model_used, requested_model: model, usage: call[:usage],
|
|
197
252
|
cost_usd: call[:cost_usd], outcome: call[:outcome] || :success,
|
|
198
253
|
decision_id: decision.id)
|
data/lib/marginfuse/types.rb
CHANGED
|
@@ -29,6 +29,23 @@ module MarginFuse
|
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# What {Client#identify} recorded, or why it could not.
|
|
33
|
+
#
|
|
34
|
+
# +ok+ is the only field to branch on. When it is false the call changed
|
|
35
|
+
# nothing and +error+ says what happened; the SDK still did not raise.
|
|
36
|
+
#
|
|
37
|
+
# Unlike {Client#track}, identify reports its failures. track has a safe
|
|
38
|
+
# default - retry later - and "I could not record what this customer pays"
|
|
39
|
+
# has none: a wrong plan is a wrong margin.
|
|
40
|
+
IdentifyResult = Struct.new(
|
|
41
|
+
:ok, :customer_id, :plan, :period_start, :period_end, :error,
|
|
42
|
+
keyword_init: true
|
|
43
|
+
) do
|
|
44
|
+
def ok?
|
|
45
|
+
!!ok
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
32
49
|
# The result of the whole guard loop. +kind+ is :completed, :blocked or
|
|
33
50
|
# :topup_required. +result+ is your block's own return value.
|
|
34
51
|
GuardOutcome = Struct.new(:kind, :decision, :result, keyword_init: true) do
|
data/lib/marginfuse/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: marginfuse
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Pemira Labs
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: |-
|
|
14
14
|
MarginFuse server-side SDK. Connect revenue to per-request AI cost, see gross
|