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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6594a128c2a5670e8eef2c8da7e21a96c305c3e13f66d4262889169c95df5a8a
4
- data.tar.gz: 96e931bd8cf686b799f306483043c965f81f683eba6d377ce3d5d4bd18d2297e
3
+ metadata.gz: ed4b052cde767f31778131e10df10f850d206c4db7f2215040de98faec393af0
4
+ data.tar.gz: 428cb94a4937c73876d998db48026aea9b2f8b69730b1149bd5e3947051a19c9
5
5
  SHA512:
6
- metadata.gz: 139be111ee7a9d54f9c93fd4b2a00712c27c7c2a8ddf0407a7806cd05a43c230dd1714be49654b053cdf3b4db756503f1516ccb42a9f54b859b63acf6924b9c2
7
- data.tar.gz: 45f3c6d52858f692f327c7be010b7a082d7ee457f027c028c7d1cec7d8128eea825600d3d40b0e1fe0f5ef3065a8c9bdea2e648f2fc8f7f7ce4730e9fb0700c4
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
@@ -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, feature: nil, requested_model: nil,
110
- cost_usd: nil, event_id: nil, occurred_at: nil, outcome: :success,
111
- decision_id: nil, retry_of_event_id: nil, corrects_event_id: nil)
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)
@@ -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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MarginFuse
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
 
6
6
  # The version of the shared SDK contract this build was verified against.
7
7
  #
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.1.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-02 00:00:00.000000000 Z
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