mbuzz 0.6.2

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.
@@ -0,0 +1,585 @@
1
+ # Conversions API - Technical Specification
2
+
3
+ **Version**: 1.1.0
4
+ **Created**: 2025-11-26
5
+ **Updated**: 2025-11-26
6
+ **Status**: Ready for Implementation
7
+
8
+ ---
9
+
10
+ ## Overview
11
+
12
+ Add conversion tracking to the mbuzz Ruby gem. Conversions are the critical attribution endpoint - they trigger the attribution calculation that credits marketing touchpoints for revenue.
13
+
14
+ **Purpose**: Allow Ruby SDK users to track conversions (purchases, signups, upgrades) and retrieve attribution data showing which marketing channels drove the conversion.
15
+
16
+ ---
17
+
18
+ ## API Design
19
+
20
+ ### Mbuzz::Client.conversion
21
+
22
+ Track a conversion event and trigger attribution calculation.
23
+
24
+ **Method Signature**:
25
+ ```ruby
26
+ Mbuzz::Client.conversion(
27
+ event_id: nil, # Identifier option A - Link to specific event
28
+ visitor_id: nil, # Identifier option B - Visitor ID (uses most recent session)
29
+ conversion_type:, # Required - Type: "purchase", "signup", "upgrade", etc.
30
+ revenue: nil, # Optional - Revenue amount (numeric)
31
+ currency: "USD", # Optional - Currency code (default: USD)
32
+ properties: {} # Optional - Additional metadata
33
+ )
34
+ ```
35
+
36
+ **Parameters**:
37
+ | Parameter | Type | Required | Description |
38
+ |-----------|------|----------|-------------|
39
+ | `event_id` | String | One of event_id/visitor_id | Prefixed event ID (`evt_*`) - visitor/session derived from event |
40
+ | `visitor_id` | String | One of event_id/visitor_id | Raw visitor ID (64-char hex from `_mbuzz_vid`) - uses most recent session |
41
+ | `conversion_type` | String | Yes | Type of conversion (e.g., "purchase", "signup") |
42
+ | `revenue` | Numeric | No | Revenue amount for ROI calculations |
43
+ | `currency` | String | No | ISO 4217 currency code (default: "USD") |
44
+ | `properties` | Hash | No | Additional conversion metadata |
45
+
46
+ **Identifier Resolution**:
47
+ - If `event_id` provided: Use event's visitor and session (most precise)
48
+ - If only `visitor_id` provided: Look up visitor, use most recent session
49
+ - If both provided: `event_id` takes precedence
50
+ - If neither provided: Return `false` (validation error)
51
+
52
+ **Returns**:
53
+ - Success: `{ success: true, conversion_id: "conv_abc123", attribution: {...} }`
54
+ - Failure: `false`
55
+
56
+ **Example Usage**:
57
+ ```ruby
58
+ # Option A: Event-based (recommended for precise attribution)
59
+ track_result = Mbuzz::Client.track(
60
+ visitor_id: mbuzz_visitor_id,
61
+ event_type: 'checkout_completed',
62
+ properties: { order_id: order.id }
63
+ )
64
+
65
+ if track_result[:success]
66
+ result = Mbuzz::Client.conversion(
67
+ event_id: track_result[:event_id],
68
+ conversion_type: 'purchase',
69
+ revenue: order.total
70
+ )
71
+ end
72
+
73
+ # Option B: Visitor-based (simpler, for direct conversions)
74
+ result = Mbuzz::Client.conversion(
75
+ visitor_id: mbuzz_visitor_id,
76
+ conversion_type: 'purchase',
77
+ revenue: 99.00,
78
+ properties: { plan: 'pro' }
79
+ )
80
+
81
+ # Access attribution data
82
+ if result[:success]
83
+ puts "Conversion ID: #{result[:conversion_id]}"
84
+
85
+ result[:attribution][:models].each do |model_name, credits|
86
+ puts "#{model_name}:"
87
+ credits.each do |credit|
88
+ puts " #{credit[:channel]}: #{(credit[:credit] * 100).round(1)}%"
89
+ end
90
+ end
91
+ end
92
+ ```
93
+
94
+ ---
95
+
96
+ ## When to Use Each Approach
97
+
98
+ | Approach | Use Case |
99
+ |----------|----------|
100
+ | **Event-based** (`event_id`) | Tie conversion to specific action (checkout button click, form submit) |
101
+ | **Visitor-based** (`visitor_id`) | Direct conversions, offline imports, webhook integrations, simpler SDK usage |
102
+
103
+ **Event-based advantages**:
104
+ - Most precise - conversion tied to exact moment in journey
105
+ - Event has full context (properties, timestamp)
106
+ - Better for debugging and analysis
107
+
108
+ **Visitor-based advantages**:
109
+ - Simpler - don't need to track an event first
110
+ - Works for offline/imported conversions
111
+ - Better for direct purchase flows (land → buy immediately)
112
+
113
+ ---
114
+
115
+ ## Backend Integration
116
+
117
+ ### API Endpoint
118
+
119
+ **POST https://mbuzz.co/api/v1/conversions**
120
+
121
+ **Request Headers**:
122
+ ```
123
+ Authorization: Bearer sk_live_abc123...
124
+ Content-Type: application/json
125
+ User-Agent: mbuzz-ruby/1.0.0
126
+ ```
127
+
128
+ **Request Body (Event-based)**:
129
+ ```json
130
+ {
131
+ "conversion": {
132
+ "event_id": "evt_abc123def456",
133
+ "conversion_type": "purchase",
134
+ "revenue": 99.00,
135
+ "properties": {
136
+ "plan": "pro"
137
+ }
138
+ }
139
+ }
140
+ ```
141
+
142
+ **Request Body (Visitor-based)**:
143
+ ```json
144
+ {
145
+ "conversion": {
146
+ "visitor_id": "65dabef8d611f332d5bb88f5d6870c733d89f962594575b66f0e1de1ede1ebf0",
147
+ "conversion_type": "purchase",
148
+ "revenue": 99.00,
149
+ "properties": {
150
+ "plan": "pro"
151
+ }
152
+ }
153
+ }
154
+ ```
155
+
156
+ **Success Response** (201 Created):
157
+ ```json
158
+ {
159
+ "id": "conv_xyz789",
160
+ "visitor_id": "vis_abc123",
161
+ "conversion_type": "purchase",
162
+ "revenue": "99.0",
163
+ "converted_at": "2025-11-26T10:30:00Z",
164
+ "attribution": {
165
+ "lookback_days": 30,
166
+ "sessions_analyzed": 3,
167
+ "models": {
168
+ "first_touch": [
169
+ {
170
+ "session_id": "sess_111",
171
+ "channel": "organic_search",
172
+ "credit": 1.0,
173
+ "revenue_credit": "99.0"
174
+ }
175
+ ],
176
+ "last_touch": [
177
+ {
178
+ "session_id": "sess_333",
179
+ "channel": "email",
180
+ "credit": 1.0,
181
+ "revenue_credit": "99.0"
182
+ }
183
+ ],
184
+ "linear": [
185
+ {
186
+ "session_id": "sess_111",
187
+ "channel": "organic_search",
188
+ "credit": 0.333,
189
+ "revenue_credit": "33.0"
190
+ },
191
+ {
192
+ "session_id": "sess_222",
193
+ "channel": "paid_social",
194
+ "credit": 0.333,
195
+ "revenue_credit": "33.0"
196
+ },
197
+ {
198
+ "session_id": "sess_333",
199
+ "channel": "email",
200
+ "credit": 0.334,
201
+ "revenue_credit": "33.0"
202
+ }
203
+ ]
204
+ }
205
+ }
206
+ }
207
+ ```
208
+
209
+ **Error Response** (422 Unprocessable Entity):
210
+ ```json
211
+ {
212
+ "errors": ["event_id or visitor_id is required"]
213
+ }
214
+ ```
215
+
216
+ ```json
217
+ {
218
+ "errors": ["Visitor not found"]
219
+ }
220
+ ```
221
+
222
+ ---
223
+
224
+ ## Attribution Models
225
+
226
+ The backend calculates attribution across all active models for the account:
227
+
228
+ ### Implemented Models
229
+
230
+ | Model | Description | Credit Distribution |
231
+ |-------|-------------|---------------------|
232
+ | `first_touch` | First session gets all credit | 100% to first session |
233
+ | `last_touch` | Last session gets all credit | 100% to last session |
234
+ | `linear` | Equal credit to all sessions | Even split across sessions |
235
+
236
+ ### Future Models (Not Yet Implemented)
237
+
238
+ | Model | Description |
239
+ |-------|-------------|
240
+ | `time_decay` | More recent sessions get more credit |
241
+ | `u_shaped` | First and last get 40% each, middle splits 20% |
242
+ | `w_shaped` | First, lead creation, last get 30% each, rest splits 10% |
243
+ | `participation` | All sessions get 100% (over-counts to show participation) |
244
+
245
+ ---
246
+
247
+ ## Implementation Details
248
+
249
+ ### Client Implementation
250
+
251
+ ```ruby
252
+ # lib/mbuzz/client.rb
253
+
254
+ def self.conversion(event_id: nil, visitor_id: nil, conversion_type:, revenue: nil, currency: "USD", properties: {})
255
+ ConversionRequest.new(event_id, visitor_id, conversion_type, revenue, currency, properties).call
256
+ end
257
+ ```
258
+
259
+ ### ConversionRequest Implementation
260
+
261
+ ```ruby
262
+ # lib/mbuzz/client/conversion_request.rb
263
+
264
+ module Mbuzz
265
+ class Client
266
+ class ConversionRequest
267
+ def initialize(event_id, visitor_id, conversion_type, revenue, currency, properties)
268
+ @event_id = event_id
269
+ @visitor_id = visitor_id
270
+ @conversion_type = conversion_type
271
+ @revenue = revenue
272
+ @currency = currency
273
+ @properties = properties
274
+ end
275
+
276
+ def call
277
+ return false unless valid?
278
+
279
+ response = Api.post_with_response(CONVERSIONS_PATH, payload)
280
+ return false unless response
281
+
282
+ {
283
+ success: true,
284
+ conversion_id: response["id"],
285
+ attribution: symbolize_attribution(response["attribution"])
286
+ }
287
+ end
288
+
289
+ private
290
+
291
+ def valid?
292
+ has_identifier? && present?(@conversion_type) && hash?(@properties)
293
+ end
294
+
295
+ def has_identifier?
296
+ present?(@event_id) || present?(@visitor_id)
297
+ end
298
+
299
+ def payload
300
+ {
301
+ conversion: base_payload
302
+ .tap { |p| p[:event_id] = @event_id if @event_id }
303
+ .tap { |p| p[:visitor_id] = @visitor_id if @visitor_id }
304
+ .tap { |p| p[:revenue] = @revenue if @revenue }
305
+ }
306
+ end
307
+
308
+ def base_payload
309
+ {
310
+ conversion_type: @conversion_type,
311
+ currency: @currency,
312
+ properties: @properties,
313
+ timestamp: Time.now.utc.iso8601
314
+ }
315
+ end
316
+
317
+ def symbolize_attribution(attr)
318
+ return nil unless attr
319
+ # Convert string keys to symbols for Ruby-friendly access
320
+ attr.transform_keys(&:to_sym)
321
+ end
322
+
323
+ def present?(value) = value && !value.to_s.strip.empty?
324
+ def hash?(value) = value.is_a?(Hash)
325
+ end
326
+ end
327
+ end
328
+ ```
329
+
330
+ ### Constants
331
+
332
+ ```ruby
333
+ # lib/mbuzz.rb
334
+
335
+ CONVERSIONS_PATH = "/conversions"
336
+ ```
337
+
338
+ ---
339
+
340
+ ## Validation Rules
341
+
342
+ ### Client-Side Validation
343
+
344
+ | Field | Rule | Error Behavior |
345
+ |-------|------|----------------|
346
+ | `event_id` OR `visitor_id` | At least one required | Return `false` |
347
+ | `conversion_type` | Required, non-empty string | Return `false` |
348
+ | `revenue` | Optional, numeric if present | Return `false` if non-numeric |
349
+ | `properties` | Optional, must be Hash | Return `false` if not Hash |
350
+
351
+ ### Backend Validation
352
+
353
+ | Field | Rule | Error Response |
354
+ |-------|------|----------------|
355
+ | `event_id` OR `visitor_id` | At least one required | 422 "event_id or visitor_id is required" |
356
+ | `event_id` | Must exist if provided | 422 "Event not found" |
357
+ | `visitor_id` | Must exist if provided | 422 "Visitor not found" |
358
+ | `conversion_type` | Non-empty string | 422 "conversion_type is required" |
359
+ | `revenue` | Numeric if present | 422 "Revenue must be numeric" |
360
+
361
+ ---
362
+
363
+ ## Error Handling
364
+
365
+ Following the gem's philosophy: **never raise exceptions**.
366
+
367
+ ```ruby
368
+ # All errors return false
369
+ result = Mbuzz::Client.conversion(conversion_type: "purchase")
370
+ # => false (no identifier)
371
+
372
+ result = Mbuzz::Client.conversion(visitor_id: "abc", conversion_type: "")
373
+ # => false (empty conversion_type)
374
+
375
+ # Network errors return false
376
+ result = Mbuzz::Client.conversion(visitor_id: "abc", conversion_type: "purchase")
377
+ # => false (if network timeout)
378
+
379
+ # Success returns hash with data
380
+ result = Mbuzz::Client.conversion(visitor_id: "abc", conversion_type: "purchase")
381
+ # => { success: true, conversion_id: "conv_xyz", attribution: {...} }
382
+ ```
383
+
384
+ ---
385
+
386
+ ## Testing
387
+
388
+ ### Unit Tests
389
+
390
+ ```ruby
391
+ # test/mbuzz/client/conversion_request_test.rb
392
+
393
+ class ConversionRequestTest < Minitest::Test
394
+ # Identifier validation
395
+ def test_requires_event_id_or_visitor_id
396
+ result = Mbuzz::Client.conversion(
397
+ conversion_type: "purchase"
398
+ )
399
+ assert_equal false, result
400
+ end
401
+
402
+ def test_accepts_event_id_only
403
+ stub_successful_conversion_response
404
+
405
+ result = Mbuzz::Client.conversion(
406
+ event_id: "evt_abc123",
407
+ conversion_type: "purchase"
408
+ )
409
+
410
+ assert result[:success]
411
+ end
412
+
413
+ def test_accepts_visitor_id_only
414
+ stub_successful_conversion_response
415
+
416
+ result = Mbuzz::Client.conversion(
417
+ visitor_id: "65dabef8d611f332d5bb88f5d6870c733d89f962594575b66f0e1de1ede1ebf0",
418
+ conversion_type: "purchase"
419
+ )
420
+
421
+ assert result[:success]
422
+ end
423
+
424
+ def test_accepts_both_identifiers
425
+ stub_successful_conversion_response
426
+
427
+ result = Mbuzz::Client.conversion(
428
+ event_id: "evt_abc123",
429
+ visitor_id: "65dabef8...",
430
+ conversion_type: "purchase"
431
+ )
432
+
433
+ assert result[:success]
434
+ end
435
+
436
+ # Conversion type validation
437
+ def test_requires_conversion_type
438
+ result = Mbuzz::Client.conversion(
439
+ visitor_id: "abc123",
440
+ conversion_type: nil
441
+ )
442
+ assert_equal false, result
443
+ end
444
+
445
+ def test_rejects_empty_conversion_type
446
+ result = Mbuzz::Client.conversion(
447
+ visitor_id: "abc123",
448
+ conversion_type: ""
449
+ )
450
+ assert_equal false, result
451
+ end
452
+
453
+ # Response handling
454
+ def test_returns_conversion_id_on_success
455
+ stub_successful_conversion_response
456
+
457
+ result = Mbuzz::Client.conversion(
458
+ event_id: "evt_abc123",
459
+ conversion_type: "purchase"
460
+ )
461
+
462
+ assert result[:conversion_id].start_with?("conv_")
463
+ end
464
+
465
+ def test_returns_attribution_data
466
+ stub_successful_conversion_response
467
+
468
+ result = Mbuzz::Client.conversion(
469
+ event_id: "evt_abc123",
470
+ conversion_type: "purchase"
471
+ )
472
+
473
+ assert result[:attribution].is_a?(Hash)
474
+ assert result[:attribution][:models].key?("first_touch")
475
+ end
476
+ end
477
+ ```
478
+
479
+ ### Integration Tests (UAT)
480
+
481
+ ```ruby
482
+ # Full attribution flow test
483
+ visitor_id = "uat_#{SecureRandom.hex(8)}"
484
+
485
+ # Create journey with 3 sessions
486
+ event_ids = []
487
+ 3.times do |i|
488
+ result = Mbuzz::Client.track(
489
+ visitor_id: visitor_id,
490
+ event_type: "page_view",
491
+ properties: {
492
+ session_id: "sess_#{i}_#{SecureRandom.hex(4)}",
493
+ utm_source: ["google", "facebook", "newsletter"][i],
494
+ utm_medium: ["organic", "paid", "email"][i]
495
+ }
496
+ )
497
+ event_ids << result[:event_id] if result[:success]
498
+ end
499
+
500
+ # Test event-based conversion
501
+ result = Mbuzz::Client.conversion(
502
+ event_id: event_ids.last,
503
+ conversion_type: "purchase",
504
+ revenue: 99.00
505
+ )
506
+
507
+ assert result[:success]
508
+ assert_equal 3, result[:attribution][:sessions_analyzed]
509
+
510
+ # Test visitor-based conversion
511
+ result2 = Mbuzz::Client.conversion(
512
+ visitor_id: visitor_id,
513
+ conversion_type: "upsell",
514
+ revenue: 49.00
515
+ )
516
+
517
+ assert result2[:success]
518
+ ```
519
+
520
+ ---
521
+
522
+ ## Usage Examples
523
+
524
+ ### Rails Controller
525
+
526
+ ```ruby
527
+ class CheckoutsController < ApplicationController
528
+ def create
529
+ @order = Order.create!(order_params)
530
+
531
+ # Option A: Event-based (recommended)
532
+ track_result = Mbuzz::Client.track(
533
+ visitor_id: mbuzz_visitor_id,
534
+ event_type: "checkout_completed",
535
+ properties: { order_id: @order.id }
536
+ )
537
+
538
+ if track_result[:success]
539
+ result = Mbuzz::Client.conversion(
540
+ event_id: track_result[:event_id],
541
+ conversion_type: "purchase",
542
+ revenue: @order.total,
543
+ properties: { items_count: @order.items.count }
544
+ )
545
+
546
+ @order.update!(
547
+ mbuzz_conversion_id: result[:conversion_id],
548
+ attribution_data: result[:attribution]
549
+ ) if result[:success]
550
+ end
551
+
552
+ redirect_to order_confirmation_path(@order)
553
+ end
554
+ end
555
+ ```
556
+
557
+ ### Background Job (Offline Conversion Import)
558
+
559
+ ```ruby
560
+ class ImportOfflineConversionJob < ApplicationJob
561
+ def perform(visitor_id, conversion_type, revenue)
562
+ # Visitor-based - no event tracking needed
563
+ result = Mbuzz::Client.conversion(
564
+ visitor_id: visitor_id,
565
+ conversion_type: conversion_type,
566
+ revenue: revenue,
567
+ properties: { source: "offline_import" }
568
+ )
569
+
570
+ Rails.logger.info "Imported conversion: #{result[:conversion_id]}" if result[:success]
571
+ end
572
+ end
573
+ ```
574
+
575
+ ---
576
+
577
+ ## Changelog
578
+
579
+ | Date | Change |
580
+ |------|--------|
581
+ | 2025-11-26 | Initial spec - support both `event_id` and `visitor_id` identifiers |
582
+
583
+ ---
584
+
585
+ Built for mbuzz.co