ridebuilder-affiliate 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +112 -0
- data/lib/ridebuilder/affiliate/capture.rb +88 -0
- data/lib/ridebuilder/affiliate/click_id.rb +16 -0
- data/lib/ridebuilder/affiliate/client.rb +156 -0
- data/lib/ridebuilder/affiliate/error.rb +18 -0
- data/lib/ridebuilder/affiliate/models.rb +11 -0
- data/lib/ridebuilder/affiliate/transport.rb +60 -0
- data/lib/ridebuilder/affiliate/version.rb +7 -0
- data/lib/ridebuilder/affiliate.rb +23 -0
- metadata +55 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 421efc119490992dea3cb78e24bb7b0c380ebc458b60f3d4253ac5669e28c429
|
|
4
|
+
data.tar.gz: 9eb9d1a0ba25f32d74ad6145e6fab9b0c382aacdf2ae539ac1274000c70e4dd1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 00b8b927dacb04a95ea7bce0e87c55479f73748d884124178db8a9638321bd395e2ddb7746ebb79a6779755441117d40df80ebd3e2613d82c9d07c8bbac474b9
|
|
7
|
+
data.tar.gz: de5af83438ff7083f3a79fe6688a6c542d8a82a0323493fc719b998445b91aafb2ca76aea679b75c248debe578daba6345ed26eb78b109ff53a2e5280fc4a046
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RideBuilder
|
|
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,112 @@
|
|
|
1
|
+
# ridebuilder-affiliate (Ruby)
|
|
2
|
+
|
|
3
|
+
Server-side SDK for RideBuilder's FirstParty affiliate program. It does two things:
|
|
4
|
+
|
|
5
|
+
1. **Capture** the `click_id` a shopper arrives with, so your backend can bind it to the cart/order.
|
|
6
|
+
2. **Report** checkout and return postbacks to RideBuilder (auth, retries, idempotency handled).
|
|
7
|
+
|
|
8
|
+
Mirrors the Node/.NET/Python/PHP/Java SDKs — same contract, verified by the shared [conformance suite](../conformance).
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
gem install ridebuilder-affiliate
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
or in a `Gemfile`: `gem "ridebuilder-affiliate"`. Requires **Ruby 3.0+**. **No runtime dependencies** — the
|
|
17
|
+
default transport uses the stdlib `net/http`.
|
|
18
|
+
|
|
19
|
+
## The pattern: capture at landing, bind to the order
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "ridebuilder/affiliate"
|
|
23
|
+
|
|
24
|
+
# 1. On landing, read a validated click_id off the request URL and persist it onto YOUR cart record.
|
|
25
|
+
click_id = RideBuilder::Affiliate::Capture.from_url(request.fullpath)
|
|
26
|
+
cart.ridebuilder_click_id = click_id if click_id
|
|
27
|
+
|
|
28
|
+
# 2. At order time, send the postback from your backend.
|
|
29
|
+
rb = RideBuilder::Affiliate::Client.new(api_key: ENV.fetch("RIDEBUILDER_API_KEY"))
|
|
30
|
+
rb.report_checkout(
|
|
31
|
+
order_id: order.id,
|
|
32
|
+
subtotal: "199.99", # major units; a string keeps it exact
|
|
33
|
+
currency: "USD",
|
|
34
|
+
click_id: order.ridebuilder_click_id
|
|
35
|
+
)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Store the API key server-side (env/secrets) — never in frontend code.
|
|
39
|
+
|
|
40
|
+
## Decoupled frontend (e.g. React) + separate Ruby backend
|
|
41
|
+
|
|
42
|
+
The browser **snippet** captures the `click_id` into a first-party cookie; get it to your backend one of two ways:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# Same registrable domain — the cookie rides along; read it off the Cookie header:
|
|
46
|
+
click_id = RideBuilder::Affiliate::Capture.from_cookie_header(request.get_header("HTTP_COOKIE"))
|
|
47
|
+
|
|
48
|
+
# Cross-domain / mobile — the frontend forwards it in the checkout call:
|
|
49
|
+
click_id = RideBuilder::Affiliate::Capture.from_headers(request.headers) # default: X-RideBuilder-Click-Id
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Either way, `report_checkout` is unchanged — that's the SDK's real value in a decoupled setup.
|
|
53
|
+
|
|
54
|
+
### Refunds
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
rb.report_return(return_id: refund.id, order_id: order.id, refund_amount: "49.95", currency: "USD")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Integration protocol (register / verify / heartbeat)
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
rb = RideBuilder::Affiliate::Client.new(api_key: api_key, environment: "production") # or "sandbox"
|
|
64
|
+
|
|
65
|
+
reg = rb.register # handshake on install/startup; returns a stable integration id
|
|
66
|
+
rb.verify # deploy/CI self-test — raises RideBuilder::Affiliate::Error on a bad/rotated key
|
|
67
|
+
rb.heartbeat # periodic liveness (call on a schedule)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
The SDK reports its own `type` (`ruby_sdk`), `version`, and default capabilities.
|
|
71
|
+
|
|
72
|
+
## Capture helpers
|
|
73
|
+
|
|
74
|
+
All validate `ref == "ridebuilder"` and the UUID-v4 click_id, returning `nil` otherwise:
|
|
75
|
+
|
|
76
|
+
- `Capture.from_url(url)` — from an absolute or relative URL.
|
|
77
|
+
- `Capture.from_query(hash)` — from a decoded query map.
|
|
78
|
+
- `Capture.from_cookie_header(cookie_header)` — recover it from the `ridebuilder_attribution` cookie.
|
|
79
|
+
- `Capture.from_headers(headers, name = "X-RideBuilder-Click-Id")` — from a forwarding header (decoupled path).
|
|
80
|
+
|
|
81
|
+
## Client options
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
RideBuilder::Affiliate::Client.new(
|
|
85
|
+
api_key:, # required
|
|
86
|
+
base_url: nil, # defaults to https://api.ridebuilder.com/v1
|
|
87
|
+
max_retries: 3, # retries on network errors, timeouts, 5xx, 429
|
|
88
|
+
timeout_ms: 10_000, # per-attempt timeout
|
|
89
|
+
environment: "production",
|
|
90
|
+
transport: nil # inject an object responding to #call(method, url, headers, body)
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`report_checkout` / `report_return` return `PostbackResult` (`.accepted`, `.status`; `202` = accepted,
|
|
95
|
+
validated asynchronously). Invalid input raises a non-retryable `RideBuilder::Affiliate::Error`; auth/size
|
|
96
|
+
failures (`401`, `413`) raise with `.status` and `.error_code`. Amounts must be `> 0` with at most 2 decimal
|
|
97
|
+
places (pass a **string** to avoid float rounding) or the call raises up front.
|
|
98
|
+
|
|
99
|
+
## Tests
|
|
100
|
+
|
|
101
|
+
Plain Ruby, no test framework required:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
ruby test/conformance.rb # the shared cross-language fixtures
|
|
105
|
+
ruby test/unit.rb # validation, capture, money, retry/error, identity
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Contract
|
|
109
|
+
|
|
110
|
+
Wraps the RideBuilder affiliate REST contract — `POST /v1/postback/checkout`, `/postback/return`,
|
|
111
|
+
`/postback/health`, the `/integration/*` endpoints, the `/redirect` link format, and API-key provisioning.
|
|
112
|
+
Verified byte-for-byte against the Node/.NET/Python/PHP/Java SDKs by the shared conformance fixtures.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RideBuilder
|
|
4
|
+
module Affiliate
|
|
5
|
+
# Framework-free capture helpers — turn an incoming request into a validated click_id (or nil). Every
|
|
6
|
+
# helper enforces ref == "ridebuilder" and the UUID-v4 click_id, the same rules the browser snippet uses.
|
|
7
|
+
module Capture
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Read a validated click_id from a raw URL (absolute or relative like "/landing?...").
|
|
11
|
+
def from_url(url)
|
|
12
|
+
return nil if url.nil? || url.empty?
|
|
13
|
+
|
|
14
|
+
query = url.split("?", 2)[1]
|
|
15
|
+
return nil if query.nil?
|
|
16
|
+
|
|
17
|
+
query = query.split("#", 2)[0]
|
|
18
|
+
from_query(parse_query(query))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Read a validated click_id from a decoded query map.
|
|
22
|
+
def from_query(query)
|
|
23
|
+
return nil unless query && query["ref"] == ClickId::REF
|
|
24
|
+
|
|
25
|
+
click_id = query["click_id"]
|
|
26
|
+
ClickId.valid?(click_id) ? click_id : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Recover the click_id from the ridebuilder_attribution cookie the browser snippet set.
|
|
30
|
+
def from_cookie_header(cookie_header)
|
|
31
|
+
return nil if cookie_header.nil? || cookie_header.empty?
|
|
32
|
+
|
|
33
|
+
raw = parse_cookie_header(cookie_header)[ClickId::COOKIE_NAME]
|
|
34
|
+
return nil if raw.nil? || raw.empty?
|
|
35
|
+
|
|
36
|
+
parse_attribution_cookie(raw)&.click_id
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Read a validated click_id off a forwarding header (case-insensitive) — the decoupled path.
|
|
40
|
+
def from_headers(headers, name = "X-RideBuilder-Click-Id")
|
|
41
|
+
return nil unless headers
|
|
42
|
+
|
|
43
|
+
target = name.downcase
|
|
44
|
+
headers.each do |key, value|
|
|
45
|
+
next unless key.to_s.downcase == target
|
|
46
|
+
|
|
47
|
+
val = value.is_a?(Array) ? value.first : value
|
|
48
|
+
return ClickId.valid?(val) ? val : nil
|
|
49
|
+
end
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Parse the raw ridebuilder_attribution cookie value (URL-encoded JSON the snippet wrote).
|
|
54
|
+
def parse_attribution_cookie(raw_value)
|
|
55
|
+
parsed = JSON.parse(CGI.unescape(raw_value))
|
|
56
|
+
return nil unless parsed.is_a?(Hash) && ClickId.valid?(parsed["click_id"]) && parsed["ref"] == ClickId::REF
|
|
57
|
+
|
|
58
|
+
Attribution.new(parsed["click_id"], parsed["ref"], parsed["clicked_at"])
|
|
59
|
+
rescue JSON::ParserError
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Split a Cookie header ("a=1; b=2") into a name=>value hash (first '=' separates).
|
|
64
|
+
def parse_cookie_header(cookie_header)
|
|
65
|
+
out = {}
|
|
66
|
+
cookie_header.split(";").each do |part|
|
|
67
|
+
i = part.index("=")
|
|
68
|
+
out[part[0...i].strip] = part[(i + 1)..].strip if i && i.positive?
|
|
69
|
+
end
|
|
70
|
+
out
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_query(query)
|
|
74
|
+
out = {}
|
|
75
|
+
return out if query.nil? || query.empty?
|
|
76
|
+
|
|
77
|
+
query.split("&").each do |pair|
|
|
78
|
+
next if pair.empty?
|
|
79
|
+
|
|
80
|
+
key, value = pair.split("=", 2)
|
|
81
|
+
key = CGI.unescape(key)
|
|
82
|
+
out[key] = value.nil? ? "" : CGI.unescape(value) unless out.key?(key)
|
|
83
|
+
end
|
|
84
|
+
out
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RideBuilder
|
|
4
|
+
module Affiliate
|
|
5
|
+
# Shared attribution constants and the strict UUID-v4 click_id check the browser snippet also enforces.
|
|
6
|
+
module ClickId
|
|
7
|
+
REF = "ridebuilder"
|
|
8
|
+
COOKIE_NAME = "ridebuilder_attribution"
|
|
9
|
+
PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
|
|
10
|
+
|
|
11
|
+
def self.valid?(value)
|
|
12
|
+
value.is_a?(String) && !(PATTERN =~ value).nil?
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RideBuilder
|
|
4
|
+
module Affiliate
|
|
5
|
+
# Server-side client for RideBuilder's FirstParty affiliate program: report checkout/return postbacks and
|
|
6
|
+
# prove integration liveness. Handles auth, per-attempt timeout, retry with exponential backoff + jitter,
|
|
7
|
+
# and idempotency. Port of the Node RideBuilderClient — same wire contract, verified by sdk/conformance.
|
|
8
|
+
class Client
|
|
9
|
+
def initialize(api_key:, base_url: nil, max_retries: 3, timeout_ms: 10_000, environment: "production", transport: nil)
|
|
10
|
+
raise Error.new("api_key must be a non-empty string", retryable: false) if api_key.nil? || api_key.strip.empty?
|
|
11
|
+
|
|
12
|
+
@api_key = api_key
|
|
13
|
+
@base_url = (base_url || DEFAULT_BASE_URL).sub(%r{/+\z}, "")
|
|
14
|
+
@max_retries = max_retries
|
|
15
|
+
@environment = environment == "sandbox" ? "sandbox" : "production"
|
|
16
|
+
@transport = transport || NetHttpTransport.new(timeout_ms)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# order_id is the server-side idempotency key, so a retried checkout never double-counts.
|
|
20
|
+
def report_checkout(order_id:, subtotal:, currency:, click_id:)
|
|
21
|
+
assert_non_empty(order_id, "order_id")
|
|
22
|
+
amount = normalize_amount(subtotal, "subtotal")
|
|
23
|
+
assert_currency(currency)
|
|
24
|
+
unless ClickId.valid?(click_id)
|
|
25
|
+
raise Error.new("click_id must be a valid RideBuilder click_id (UUID v4)", retryable: false)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
res = send_request("POST", "/postback/checkout", {
|
|
29
|
+
"order_id" => order_id, "order_subtotal" => amount, "currency" => currency,
|
|
30
|
+
"ref" => ClickId::REF, "click_id" => click_id
|
|
31
|
+
})
|
|
32
|
+
PostbackResult.new(true, res.status)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# return_id is the server-side idempotency key, so a retried refund never double-counts.
|
|
36
|
+
def report_return(return_id:, order_id:, refund_amount:, currency:)
|
|
37
|
+
assert_non_empty(return_id, "return_id")
|
|
38
|
+
assert_non_empty(order_id, "order_id")
|
|
39
|
+
amount = normalize_amount(refund_amount, "refund_amount")
|
|
40
|
+
assert_currency(currency)
|
|
41
|
+
|
|
42
|
+
res = send_request("POST", "/postback/return", {
|
|
43
|
+
"return_id" => return_id, "order_id" => order_id, "refund_amount" => amount, "currency" => currency
|
|
44
|
+
})
|
|
45
|
+
PostbackResult.new(true, res.status)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Authenticated liveness ping (legacy alias). Raises a terminal Error on a bad key.
|
|
49
|
+
def health_check
|
|
50
|
+
HealthResult.new(true, send_request("POST", "/postback/health").status)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Announce this integration on install/startup — the handshake. Idempotent; returns the integration id.
|
|
54
|
+
def register(capabilities: nil)
|
|
55
|
+
res = send_request("POST", "/integration/register", {
|
|
56
|
+
"type" => SDK_TYPE, "environment" => @environment, "version" => VERSION,
|
|
57
|
+
"capabilities" => capabilities || DEFAULT_CAPABILITIES
|
|
58
|
+
})
|
|
59
|
+
body = res.json.is_a?(Hash) ? res.json : {}
|
|
60
|
+
RegisterResult.new(body["integrationId"] || "", body["status"] || "connected")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Self-test: verifies the API key is valid + active. Raises a terminal Error on a bad/rotated key.
|
|
64
|
+
def verify
|
|
65
|
+
HealthResult.new(true, send_request("GET", "/integration/verify").status)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Periodic liveness. Call on a schedule so RideBuilder can tell "alive" from "went dark".
|
|
69
|
+
def heartbeat
|
|
70
|
+
res = send_request("POST", "/integration/heartbeat", {
|
|
71
|
+
"type" => SDK_TYPE, "environment" => @environment, "version" => VERSION
|
|
72
|
+
})
|
|
73
|
+
HealthResult.new(true, res.status)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Shared transport: auth header, retry with backoff on 5xx/429/network, terminal raise on other 4xx.
|
|
79
|
+
def send_request(method, path, payload = nil)
|
|
80
|
+
url = @base_url + path
|
|
81
|
+
body = payload ? JSON.generate(payload) : nil
|
|
82
|
+
headers = { "Authorization" => "Bearer #{@api_key}" }
|
|
83
|
+
headers["Content-Type"] = "application/json" if body
|
|
84
|
+
|
|
85
|
+
last_error = nil
|
|
86
|
+
(0..@max_retries).each do |attempt|
|
|
87
|
+
sleep(backoff_ms(attempt) / 1000.0) if attempt.positive?
|
|
88
|
+
|
|
89
|
+
begin
|
|
90
|
+
res = @transport.call(method, url, headers, body)
|
|
91
|
+
rescue TransportError => e
|
|
92
|
+
last_error = Error.new(e.message, retryable: true)
|
|
93
|
+
next
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
case classify(res.status)
|
|
97
|
+
when :success
|
|
98
|
+
return res
|
|
99
|
+
when :retry
|
|
100
|
+
last_error = Error.new("RideBuilder request failed with #{res.status}", retryable: true, status: res.status)
|
|
101
|
+
next
|
|
102
|
+
else
|
|
103
|
+
code = error_code(res)
|
|
104
|
+
message = code ? "RideBuilder request rejected (#{res.status}): #{code}" : "RideBuilder request rejected with #{res.status}"
|
|
105
|
+
raise Error.new(message, retryable: false, status: res.status, error_code: code)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
raise(last_error || Error.new("RideBuilder request failed after retries", retryable: true))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def classify(status)
|
|
113
|
+
return :success if (200..299).cover?(status)
|
|
114
|
+
return :retry if status >= 500 || status == 429
|
|
115
|
+
|
|
116
|
+
:terminal
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def error_code(res)
|
|
120
|
+
body = res.json
|
|
121
|
+
body.is_a?(Hash) && body["error"].is_a?(String) ? body["error"] : nil
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# 250 * 2^(attempt-1) ms + up to 100 ms jitter — identical to the Node/.NET/Python/PHP/Java backoff.
|
|
125
|
+
def backoff_ms(attempt)
|
|
126
|
+
250 * (2**(attempt - 1)) + rand(0..99)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def assert_non_empty(value, field)
|
|
130
|
+
return if value.is_a?(String) && !value.strip.empty?
|
|
131
|
+
|
|
132
|
+
raise Error.new("#{field} must be a non-empty string", retryable: false)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def assert_currency(value)
|
|
136
|
+
return if value.is_a?(String) && value.match?(/\A[A-Z]{3}\z/)
|
|
137
|
+
|
|
138
|
+
raise Error.new("currency must be a 3-letter uppercase ISO-4217 code", retryable: false)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Validate an amount and return it as a Float for JSON-number emission. Accepts Integer/Float/String;
|
|
142
|
+
# rejects <= 0 and > 2 decimal places up front (matching the stricter .NET/Python/Java clients).
|
|
143
|
+
def normalize_amount(value, field)
|
|
144
|
+
str = case value
|
|
145
|
+
when Integer, Float then value.to_s
|
|
146
|
+
when String then value.strip
|
|
147
|
+
end
|
|
148
|
+
num = str.nil? ? nil : (Float(str) rescue nil)
|
|
149
|
+
raise Error.new("#{field} must be a positive number", retryable: false) if num.nil? || num <= 0
|
|
150
|
+
raise Error.new("#{field} must have at most 2 decimal places", retryable: false) unless str.match?(/\A\d+(\.\d{1,2})?\z/)
|
|
151
|
+
|
|
152
|
+
num
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RideBuilder
|
|
4
|
+
module Affiliate
|
|
5
|
+
# Every failure the SDK raises. +retryable+ is false for input-validation and terminal 4xx errors, true
|
|
6
|
+
# for 5xx/429/network failures (surfaced only after retries are exhausted). Port of the Node RideBuilderError.
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
attr_reader :status, :error_code, :retryable
|
|
9
|
+
|
|
10
|
+
def initialize(message, retryable:, status: nil, error_code: nil)
|
|
11
|
+
super(message)
|
|
12
|
+
@status = status
|
|
13
|
+
@error_code = error_code
|
|
14
|
+
@retryable = retryable
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RideBuilder
|
|
4
|
+
module Affiliate
|
|
5
|
+
# A 202 status means received, not yet validated — RideBuilder validates asynchronously.
|
|
6
|
+
PostbackResult = Struct.new(:accepted, :status)
|
|
7
|
+
HealthResult = Struct.new(:ok, :status)
|
|
8
|
+
RegisterResult = Struct.new(:integration_id, :status)
|
|
9
|
+
Attribution = Struct.new(:click_id, :ref, :clicked_at)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module RideBuilder
|
|
8
|
+
module Affiliate
|
|
9
|
+
# A network-level failure (connect/DNS/timeout). Always treated as retryable by the client.
|
|
10
|
+
class TransportError < StandardError; end
|
|
11
|
+
|
|
12
|
+
# A completed HTTP response — any status. Non-2xx is returned here, not raised.
|
|
13
|
+
Response = Struct.new(:status, :body) do
|
|
14
|
+
def json
|
|
15
|
+
return nil if body.nil? || body.empty?
|
|
16
|
+
|
|
17
|
+
JSON.parse(body)
|
|
18
|
+
rescue JSON::ParserError
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Default transport, backed by the stdlib Net::HTTP — zero third-party dependencies. Inject any object
|
|
24
|
+
# that responds to #call(method, url, headers, body) to route through your own HTTP client.
|
|
25
|
+
class NetHttpTransport
|
|
26
|
+
def initialize(timeout_ms = 10_000)
|
|
27
|
+
@timeout = timeout_ms / 1000.0
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(method, url, headers, body)
|
|
31
|
+
uri = URI(url)
|
|
32
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
33
|
+
http.use_ssl = uri.scheme == "https"
|
|
34
|
+
http.open_timeout = @timeout
|
|
35
|
+
http.read_timeout = @timeout
|
|
36
|
+
|
|
37
|
+
request = build_request(method, uri)
|
|
38
|
+
headers.each { |k, v| request[k] = v }
|
|
39
|
+
request.body = body if body
|
|
40
|
+
|
|
41
|
+
response = http.request(request)
|
|
42
|
+
Response.new(response.code.to_i, response.body)
|
|
43
|
+
rescue SocketError, SystemCallError, IOError, Timeout::Error => e
|
|
44
|
+
raise TransportError, e.message
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def build_request(method, uri)
|
|
50
|
+
case method
|
|
51
|
+
when "GET" then Net::HTTP::Get.new(uri)
|
|
52
|
+
when "POST" then Net::HTTP::Post.new(uri)
|
|
53
|
+
when "PUT" then Net::HTTP::Put.new(uri)
|
|
54
|
+
when "DELETE" then Net::HTTP::Delete.new(uri)
|
|
55
|
+
else raise ArgumentError, "unsupported method #{method}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "cgi"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module RideBuilder
|
|
9
|
+
# Server-side SDK for RideBuilder's FirstParty affiliate program.
|
|
10
|
+
module Affiliate
|
|
11
|
+
DEFAULT_BASE_URL = "https://api.ridebuilder.com/v1"
|
|
12
|
+
SDK_TYPE = "ruby_sdk"
|
|
13
|
+
DEFAULT_CAPABILITIES = %w[click_capture order_events refund_events].freeze
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
require_relative "affiliate/version"
|
|
18
|
+
require_relative "affiliate/error"
|
|
19
|
+
require_relative "affiliate/click_id"
|
|
20
|
+
require_relative "affiliate/models"
|
|
21
|
+
require_relative "affiliate/capture"
|
|
22
|
+
require_relative "affiliate/transport"
|
|
23
|
+
require_relative "affiliate/client"
|
metadata
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ridebuilder-affiliate
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- RideBuilder
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-03 00:00:00.000000000 Z
|
|
12
|
+
dependencies: []
|
|
13
|
+
description: Capture click_id and send checkout/return postbacks to RideBuilder. Zero
|
|
14
|
+
runtime dependencies (stdlib net/http + json).
|
|
15
|
+
email:
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- LICENSE
|
|
21
|
+
- README.md
|
|
22
|
+
- lib/ridebuilder/affiliate.rb
|
|
23
|
+
- lib/ridebuilder/affiliate/capture.rb
|
|
24
|
+
- lib/ridebuilder/affiliate/click_id.rb
|
|
25
|
+
- lib/ridebuilder/affiliate/client.rb
|
|
26
|
+
- lib/ridebuilder/affiliate/error.rb
|
|
27
|
+
- lib/ridebuilder/affiliate/models.rb
|
|
28
|
+
- lib/ridebuilder/affiliate/transport.rb
|
|
29
|
+
- lib/ridebuilder/affiliate/version.rb
|
|
30
|
+
homepage: https://ridebuilder.com
|
|
31
|
+
licenses:
|
|
32
|
+
- MIT
|
|
33
|
+
metadata:
|
|
34
|
+
source_code_uri: https://github.com/RideBuilder/Backend
|
|
35
|
+
rubygems_mfa_required: 'true'
|
|
36
|
+
post_install_message:
|
|
37
|
+
rdoc_options: []
|
|
38
|
+
require_paths:
|
|
39
|
+
- lib
|
|
40
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
41
|
+
requirements:
|
|
42
|
+
- - ">="
|
|
43
|
+
- !ruby/object:Gem::Version
|
|
44
|
+
version: '3.0'
|
|
45
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
46
|
+
requirements:
|
|
47
|
+
- - ">="
|
|
48
|
+
- !ruby/object:Gem::Version
|
|
49
|
+
version: '0'
|
|
50
|
+
requirements: []
|
|
51
|
+
rubygems_version: 3.5.22
|
|
52
|
+
signing_key:
|
|
53
|
+
specification_version: 4
|
|
54
|
+
summary: Server-side SDK for RideBuilder FirstParty affiliate tracking.
|
|
55
|
+
test_files: []
|