agentbill-sdk 5.0.1 → 7.17.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/.github/workflows/ci.yml +5 -4
- data/CHANGELOG.md +29 -0
- data/examples/ollama_basic.rb +81 -0
- data/examples/perplexity_basic.rb +66 -0
- data/lib/agentbill/agents.rb +226 -0
- data/lib/agentbill/customers.rb +164 -0
- data/lib/agentbill/distributed.rb +109 -0
- data/lib/agentbill/exceptions.rb +84 -0
- data/lib/agentbill/ollama_wrapper.rb +153 -0
- data/lib/agentbill/orders.rb +283 -0
- data/lib/agentbill/perplexity_wrapper.rb +101 -0
- data/lib/agentbill/pricing.rb +52 -0
- data/lib/agentbill/signal_types.rb +179 -0
- data/lib/agentbill/signals.rb +199 -0
- data/lib/agentbill/tracer.rb +68 -11
- data/lib/agentbill/tracing.rb +343 -0
- data/lib/agentbill/version.rb +1 -1
- data/lib/agentbill/wrappers.rb +384 -0
- data/lib/agentbill.rb +252 -45
- metadata +16 -2
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# AgentBill Distributed Tracing for Ruby
|
|
2
|
+
#
|
|
3
|
+
# Functions for cross-service trace propagation and correlation.
|
|
4
|
+
# This is the SINGLE SOURCE OF TRUTH for trace context in the Ruby SDK.
|
|
5
|
+
|
|
6
|
+
module AgentBill
|
|
7
|
+
module Distributed
|
|
8
|
+
# Thread-local storage for trace context
|
|
9
|
+
@trace_context = {}
|
|
10
|
+
@tracing_token = nil
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
# Get the current trace context (trace_id, span_id)
|
|
14
|
+
# This is the SINGLE SOURCE OF TRUTH used by the tracer.
|
|
15
|
+
def get_trace_context
|
|
16
|
+
Thread.current[:agentbill_trace_context]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Set the trace context manually.
|
|
20
|
+
# Called by the tracer after starting a span.
|
|
21
|
+
def set_trace_context(trace_id, span_id)
|
|
22
|
+
Thread.current[:agentbill_trace_context] = {
|
|
23
|
+
trace_id: trace_id,
|
|
24
|
+
span_id: span_id
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Clear the current trace context
|
|
29
|
+
def clear_trace_context
|
|
30
|
+
Thread.current[:agentbill_trace_context] = nil
|
|
31
|
+
Thread.current[:agentbill_tracing_token] = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get the current tracing token
|
|
35
|
+
def get_tracing_token
|
|
36
|
+
Thread.current[:agentbill_tracing_token]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Generate a new tracing token for distributed trace propagation
|
|
40
|
+
def generate_tracing_token
|
|
41
|
+
ctx = get_trace_context
|
|
42
|
+
random_suffix = SecureRandom.hex(4)
|
|
43
|
+
|
|
44
|
+
if ctx
|
|
45
|
+
token = "agentbill-v1-#{ctx[:trace_id]}-#{ctx[:span_id]}-#{random_suffix}"
|
|
46
|
+
else
|
|
47
|
+
trace_id = SecureRandom.hex(16)
|
|
48
|
+
span_id = SecureRandom.hex(8)
|
|
49
|
+
set_trace_context(trace_id, span_id)
|
|
50
|
+
token = "agentbill-v1-#{trace_id}-#{span_id}-#{random_suffix}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
Thread.current[:agentbill_tracing_token] = token
|
|
54
|
+
token
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Set the tracing token received from an upstream service
|
|
58
|
+
def set_tracing_token(token)
|
|
59
|
+
return false unless token&.start_with?('agentbill-v1-')
|
|
60
|
+
|
|
61
|
+
parts = token.split('-')
|
|
62
|
+
return false if parts.length < 5
|
|
63
|
+
|
|
64
|
+
trace_id = parts[2]
|
|
65
|
+
span_id = parts[3]
|
|
66
|
+
|
|
67
|
+
set_trace_context(trace_id, span_id)
|
|
68
|
+
Thread.current[:agentbill_tracing_token] = token
|
|
69
|
+
true
|
|
70
|
+
rescue
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Get HTTP headers for trace propagation
|
|
75
|
+
def propagate_trace_headers
|
|
76
|
+
headers = {}
|
|
77
|
+
|
|
78
|
+
token = get_tracing_token
|
|
79
|
+
headers['X-AgentBill-Trace'] = token if token
|
|
80
|
+
|
|
81
|
+
ctx = get_trace_context
|
|
82
|
+
if ctx
|
|
83
|
+
headers['traceparent'] = "00-#{ctx[:trace_id]}-#{ctx[:span_id]}-01"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
headers
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Extract trace context from incoming HTTP headers
|
|
90
|
+
def extract_trace_from_headers(headers)
|
|
91
|
+
# Try AgentBill token first
|
|
92
|
+
token = headers['X-AgentBill-Trace'] || headers['x-agentbill-trace']
|
|
93
|
+
return true if token && set_tracing_token(token)
|
|
94
|
+
|
|
95
|
+
# Try W3C traceparent
|
|
96
|
+
traceparent = headers['traceparent'] || headers['Traceparent']
|
|
97
|
+
if traceparent
|
|
98
|
+
parts = traceparent.split('-')
|
|
99
|
+
if parts.length >= 3
|
|
100
|
+
set_trace_context(parts[1], parts[2])
|
|
101
|
+
return true
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
false
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# AgentBill Exceptions
|
|
4
|
+
#
|
|
5
|
+
# Custom exception classes for Cost Guard protection and error handling.
|
|
6
|
+
|
|
7
|
+
module AgentBill
|
|
8
|
+
# Base class for all AgentBill errors
|
|
9
|
+
class AgentBillError < StandardError
|
|
10
|
+
attr_reader :details
|
|
11
|
+
|
|
12
|
+
def initialize(message, details = {})
|
|
13
|
+
@details = details
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Raised when a request would exceed the configured budget limits.
|
|
19
|
+
#
|
|
20
|
+
# This is thrown by the context manager or wrapper when ai-cost-guard-router
|
|
21
|
+
# returns allowed=false due to budget constraints.
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# begin
|
|
25
|
+
# AgentBill.tracing(customer_id: 'cust-123', daily_budget: 10.00) do |ctx|
|
|
26
|
+
# response = openai.chat.completions.create(...)
|
|
27
|
+
# end
|
|
28
|
+
# rescue AgentBill::BudgetExceededError => e
|
|
29
|
+
# puts "Budget exceeded: #{e.reason}"
|
|
30
|
+
# # Handle gracefully - queue for later, show user message, etc.
|
|
31
|
+
# end
|
|
32
|
+
class BudgetExceededError < AgentBillError
|
|
33
|
+
attr_reader :reason, :code
|
|
34
|
+
|
|
35
|
+
def initialize(reason, details = {})
|
|
36
|
+
@reason = reason
|
|
37
|
+
@code = 'BUDGET_EXCEEDED'
|
|
38
|
+
super("Budget exceeded: #{reason}", details)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Raised when a request would exceed rate limits.
|
|
43
|
+
class RateLimitExceededError < AgentBillError
|
|
44
|
+
attr_reader :reason, :code
|
|
45
|
+
|
|
46
|
+
def initialize(reason, details = {})
|
|
47
|
+
@reason = reason
|
|
48
|
+
@code = 'RATE_LIMIT_EXCEEDED'
|
|
49
|
+
super("Rate limit exceeded: #{reason}", details)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Raised when a request violates a Cost Guard policy.
|
|
54
|
+
class PolicyViolationError < AgentBillError
|
|
55
|
+
attr_reader :reason, :code
|
|
56
|
+
|
|
57
|
+
def initialize(reason, details = {})
|
|
58
|
+
@reason = reason
|
|
59
|
+
@code = 'POLICY_VIOLATION'
|
|
60
|
+
super("Policy violation: #{reason}", details)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Raised when input validation fails.
|
|
65
|
+
class ValidationError < AgentBillError
|
|
66
|
+
attr_reader :field, :code
|
|
67
|
+
|
|
68
|
+
def initialize(message, field = nil)
|
|
69
|
+
@field = field
|
|
70
|
+
@code = 'VALIDATION_ERROR'
|
|
71
|
+
super("Validation error: #{message}")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Raised when tracing operations fail.
|
|
76
|
+
class TracingError < AgentBillError
|
|
77
|
+
attr_reader :code
|
|
78
|
+
|
|
79
|
+
def initialize(message, details = {})
|
|
80
|
+
@code = 'TRACING_ERROR'
|
|
81
|
+
super("Tracing error: #{message}", details)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Ollama Wrapper for AgentBill Ruby SDK (Local, $0 cost)
|
|
2
|
+
require_relative 'pricing'
|
|
3
|
+
|
|
4
|
+
module AgentBill
|
|
5
|
+
class OllamaWrapper
|
|
6
|
+
def initialize(client, config, tracer)
|
|
7
|
+
@client = client
|
|
8
|
+
@config = config
|
|
9
|
+
@tracer = tracer
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def wrap
|
|
13
|
+
wrap_chat
|
|
14
|
+
wrap_generate
|
|
15
|
+
@client
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def wrap_chat
|
|
21
|
+
original_chat = @client.method(:chat)
|
|
22
|
+
config = @config
|
|
23
|
+
tracer = @tracer
|
|
24
|
+
|
|
25
|
+
@client.define_singleton_method(:chat) do |params|
|
|
26
|
+
model = params[:model] || 'llama2'
|
|
27
|
+
|
|
28
|
+
start_time = Time.now
|
|
29
|
+
span = tracer.start_span('ollama.chat', {
|
|
30
|
+
# ✅ OTEL GenAI compliant attributes
|
|
31
|
+
'gen_ai.system' => 'ollama',
|
|
32
|
+
'gen_ai.request.model' => model,
|
|
33
|
+
'gen_ai.operation.name' => 'chat',
|
|
34
|
+
# ⚠️ Backward compatibility
|
|
35
|
+
'model' => model,
|
|
36
|
+
'provider' => 'ollama'
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
begin
|
|
40
|
+
response = original_chat.call(params)
|
|
41
|
+
latency = ((Time.now - start_time) * 1000).round
|
|
42
|
+
|
|
43
|
+
input_tokens = response[:prompt_eval_count] || 0
|
|
44
|
+
output_tokens = response[:eval_count] || 0
|
|
45
|
+
|
|
46
|
+
track_usage(model, 'ollama', input_tokens, output_tokens, latency, 0, config)
|
|
47
|
+
|
|
48
|
+
span.set_attributes({
|
|
49
|
+
# ✅ OTEL GenAI compliant attributes
|
|
50
|
+
'gen_ai.usage.input_tokens' => input_tokens,
|
|
51
|
+
'gen_ai.usage.output_tokens' => output_tokens,
|
|
52
|
+
# ⚠️ Backward compatibility
|
|
53
|
+
'response.prompt_eval_count' => input_tokens,
|
|
54
|
+
'response.eval_count' => output_tokens,
|
|
55
|
+
'latency_ms' => latency,
|
|
56
|
+
'cost' => 0
|
|
57
|
+
})
|
|
58
|
+
span.set_status(0)
|
|
59
|
+
|
|
60
|
+
puts "[AgentBill] ✓ Ollama chat tracked (local, $0.00)" if config[:debug]
|
|
61
|
+
response
|
|
62
|
+
rescue => e
|
|
63
|
+
span.set_status(1, e.message)
|
|
64
|
+
raise
|
|
65
|
+
ensure
|
|
66
|
+
span.finish
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def wrap_generate
|
|
72
|
+
original_generate = @client.method(:generate)
|
|
73
|
+
config = @config
|
|
74
|
+
tracer = @tracer
|
|
75
|
+
|
|
76
|
+
@client.define_singleton_method(:generate) do |params|
|
|
77
|
+
model = params[:model] || 'llama2'
|
|
78
|
+
|
|
79
|
+
start_time = Time.now
|
|
80
|
+
span = tracer.start_span('ollama.generate', {
|
|
81
|
+
# ✅ OTEL GenAI compliant attributes
|
|
82
|
+
'gen_ai.system' => 'ollama',
|
|
83
|
+
'gen_ai.request.model' => model,
|
|
84
|
+
'gen_ai.operation.name' => 'text_completion',
|
|
85
|
+
# ⚠️ Backward compatibility
|
|
86
|
+
'model' => model,
|
|
87
|
+
'provider' => 'ollama'
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
begin
|
|
91
|
+
response = original_generate.call(params)
|
|
92
|
+
latency = ((Time.now - start_time) * 1000).round
|
|
93
|
+
|
|
94
|
+
input_tokens = response[:prompt_eval_count] || 0
|
|
95
|
+
output_tokens = response[:eval_count] || 0
|
|
96
|
+
|
|
97
|
+
track_usage(model, 'ollama', input_tokens, output_tokens, latency, 0, config)
|
|
98
|
+
|
|
99
|
+
span.set_attributes({
|
|
100
|
+
# ✅ OTEL GenAI compliant attributes
|
|
101
|
+
'gen_ai.usage.input_tokens' => input_tokens,
|
|
102
|
+
'gen_ai.usage.output_tokens' => output_tokens,
|
|
103
|
+
# ⚠️ Backward compatibility
|
|
104
|
+
'response.prompt_eval_count' => input_tokens,
|
|
105
|
+
'response.eval_count' => output_tokens,
|
|
106
|
+
'latency_ms' => latency,
|
|
107
|
+
'cost' => 0
|
|
108
|
+
})
|
|
109
|
+
span.set_status(0)
|
|
110
|
+
|
|
111
|
+
puts "[AgentBill] ✓ Ollama generate tracked (local, $0.00)" if config[:debug]
|
|
112
|
+
response
|
|
113
|
+
rescue => e
|
|
114
|
+
span.set_status(1, e.message)
|
|
115
|
+
raise
|
|
116
|
+
ensure
|
|
117
|
+
span.finish
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def track_usage(model, provider, input_tokens, output_tokens, latency, cost, config)
|
|
123
|
+
uri = URI("#{config[:base_url] || 'https://api.agentbill.io'}/functions/v1/track-ai-usage")
|
|
124
|
+
|
|
125
|
+
payload = {
|
|
126
|
+
api_key: config[:api_key],
|
|
127
|
+
customer_id: config[:customer_id],
|
|
128
|
+
agent_id: config[:agent_id],
|
|
129
|
+
event_name: 'ai_request',
|
|
130
|
+
model: model,
|
|
131
|
+
provider: provider,
|
|
132
|
+
prompt_tokens: input_tokens,
|
|
133
|
+
completion_tokens: output_tokens,
|
|
134
|
+
latency_ms: latency,
|
|
135
|
+
cost: cost
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
140
|
+
http.use_ssl = true
|
|
141
|
+
http.read_timeout = 10
|
|
142
|
+
|
|
143
|
+
request = Net::HTTP::Post.new(uri.path)
|
|
144
|
+
request['Content-Type'] = 'application/json'
|
|
145
|
+
request.body = payload.to_json
|
|
146
|
+
|
|
147
|
+
http.request(request)
|
|
148
|
+
rescue => e
|
|
149
|
+
puts "[AgentBill] Tracking failed: #{e.message}" if config[:debug]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# AgentBill Orders Resource - CRUD operations with line items
|
|
2
|
+
#
|
|
3
|
+
# IMPORTANT: At least one agent must be associated with an order via
|
|
4
|
+
# agent_ids or agent_external_ids. Orders cannot be created without agents.
|
|
5
|
+
#
|
|
6
|
+
# Example:
|
|
7
|
+
# ab = AgentBill::Client.init(api_key: 'agb_...')
|
|
8
|
+
#
|
|
9
|
+
# # List orders
|
|
10
|
+
# result = ab.orders.list(limit: 10)
|
|
11
|
+
#
|
|
12
|
+
# # Create order (agent_ids or agent_external_ids REQUIRED)
|
|
13
|
+
# order = ab.orders.create(
|
|
14
|
+
# account_id: 'acct-123',
|
|
15
|
+
# name: 'Enterprise Q1',
|
|
16
|
+
# agent_ids: ['agent-uuid-1'] # REQUIRED
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# # Add line item
|
|
20
|
+
# ab.orders.add_line_item(
|
|
21
|
+
# order_id: order['id'],
|
|
22
|
+
# description: 'AI Usage',
|
|
23
|
+
# quantity: 1000,
|
|
24
|
+
# unit_price: 0.05,
|
|
25
|
+
# item_type: 'usage'
|
|
26
|
+
# )
|
|
27
|
+
#
|
|
28
|
+
# # Get order by ID
|
|
29
|
+
# order = ab.orders.get('order-123')
|
|
30
|
+
#
|
|
31
|
+
# # Update order
|
|
32
|
+
# order = ab.orders.update('order-123', status: 'active')
|
|
33
|
+
#
|
|
34
|
+
# # Delete order
|
|
35
|
+
# ab.orders.delete('order-123')
|
|
36
|
+
|
|
37
|
+
require 'net/http'
|
|
38
|
+
require 'json'
|
|
39
|
+
|
|
40
|
+
module AgentBill
|
|
41
|
+
# Valid item_type values for add_line_item:
|
|
42
|
+
# - usage: Variable consumption billing (API calls, tokens, requests)
|
|
43
|
+
# - platform_fee: Platform access and service charges
|
|
44
|
+
# - setup_fee: One-time onboarding and implementation costs
|
|
45
|
+
# - base_fee: Fixed recurring monthly/annual charges
|
|
46
|
+
# - overage: Usage exceeding included plan amounts
|
|
47
|
+
# - custom: Flexible billing for unique scenarios
|
|
48
|
+
# - flat_rate: Fixed-price service packages or retainers
|
|
49
|
+
# - product: Physical or digital product sales
|
|
50
|
+
# - service: Professional services and consulting
|
|
51
|
+
# - subscription: Recurring subscription billing
|
|
52
|
+
VALID_ITEM_TYPES = %w[usage platform_fee setup_fee base_fee overage custom product service subscription flat_rate].freeze
|
|
53
|
+
|
|
54
|
+
class OrdersResource
|
|
55
|
+
def initialize(config)
|
|
56
|
+
@config = config
|
|
57
|
+
@base_url = config[:base_url] || 'https://api.agentbill.io'
|
|
58
|
+
@api_key = config[:api_key]
|
|
59
|
+
@debug = config[:debug] || false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# List orders with pagination and filtering
|
|
63
|
+
#
|
|
64
|
+
# @param account_id [String, nil] Filter by account ID
|
|
65
|
+
# @param status [String, nil] Filter by status (draft, active, completed, cancelled)
|
|
66
|
+
# @param limit [Integer] Number of orders to return (default: 50)
|
|
67
|
+
# @param offset [Integer] Offset for pagination (default: 0)
|
|
68
|
+
# @param search [String, nil] Search term to filter by name or external_id
|
|
69
|
+
# @return [Hash] Hash with 'data' (list of orders) and 'pagination' info
|
|
70
|
+
def list(account_id: nil, status: nil, limit: 50, offset: 0, search: nil)
|
|
71
|
+
uri = URI("#{@base_url}/functions/v1/api-orders")
|
|
72
|
+
params = { limit: limit.to_s, offset: offset.to_s }
|
|
73
|
+
params[:search] = search if search
|
|
74
|
+
params[:account_id] = account_id if account_id
|
|
75
|
+
params[:status] = status if status
|
|
76
|
+
uri.query = URI.encode_www_form(params)
|
|
77
|
+
|
|
78
|
+
response = make_request(:get, uri)
|
|
79
|
+
handle_response(response, 'list orders')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Create a new order
|
|
83
|
+
#
|
|
84
|
+
# IMPORTANT: At least one agent must be specified via agent_ids or agent_external_ids.
|
|
85
|
+
# Orders cannot be created without agent association for proper billing attribution.
|
|
86
|
+
#
|
|
87
|
+
# @param account_id [String] Account (customer) UUID (required unless account_external_id provided)
|
|
88
|
+
# @param name [String] Order name (required)
|
|
89
|
+
# @param agent_ids [Array<String>, nil] List of agent UUIDs (required if agent_external_ids not provided)
|
|
90
|
+
# @param agent_external_ids [Array<String>, nil] List of agent external IDs (required if agent_ids not provided)
|
|
91
|
+
# @param external_id [String, nil] Your external ID for this order
|
|
92
|
+
# @param account_external_id [String, nil] External ID of the account
|
|
93
|
+
# @param billing_contact_id [String, nil] Contact UUID for billing
|
|
94
|
+
# @param billing_contact_external_id [String, nil] External ID of billing contact
|
|
95
|
+
# @param start_date [String, nil] Order start date (ISO format)
|
|
96
|
+
# @param end_date [String, nil] Order end date (ISO format)
|
|
97
|
+
# @param status [String, nil] Order status (draft, active, completed, cancelled)
|
|
98
|
+
# @param currency [String, nil] Currency code (default: USD)
|
|
99
|
+
# @param metadata [Hash, nil] Additional metadata
|
|
100
|
+
# @return [Hash] Created order object
|
|
101
|
+
# @raise [ArgumentError] If neither agent_ids nor agent_external_ids provided
|
|
102
|
+
def create(account_id:, name:, agent_ids: nil, agent_external_ids: nil, external_id: nil,
|
|
103
|
+
account_external_id: nil, billing_contact_id: nil, billing_contact_external_id: nil,
|
|
104
|
+
start_date: nil, end_date: nil, status: nil, currency: nil, metadata: nil)
|
|
105
|
+
# Validate agent requirement
|
|
106
|
+
if (agent_ids.nil? || agent_ids.empty?) && (agent_external_ids.nil? || agent_external_ids.empty?)
|
|
107
|
+
raise ArgumentError, 'At least one agent must be specified via agent_ids or agent_external_ids. ' \
|
|
108
|
+
'Orders cannot be created without agent association for billing attribution.'
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
uri = URI("#{@base_url}/functions/v1/api-orders")
|
|
112
|
+
|
|
113
|
+
payload = { account_id: account_id, name: name }
|
|
114
|
+
payload[:agent_ids] = agent_ids if agent_ids&.any?
|
|
115
|
+
payload[:agent_external_ids] = agent_external_ids if agent_external_ids&.any?
|
|
116
|
+
payload[:external_id] = external_id if external_id
|
|
117
|
+
payload[:account_external_id] = account_external_id if account_external_id
|
|
118
|
+
payload[:billing_contact_id] = billing_contact_id if billing_contact_id
|
|
119
|
+
payload[:billing_contact_external_id] = billing_contact_external_id if billing_contact_external_id
|
|
120
|
+
payload[:start_date] = start_date if start_date
|
|
121
|
+
payload[:end_date] = end_date if end_date
|
|
122
|
+
payload[:status] = status if status
|
|
123
|
+
payload[:currency] = currency if currency
|
|
124
|
+
payload[:metadata] = metadata if metadata
|
|
125
|
+
|
|
126
|
+
response = make_request(:post, uri, payload)
|
|
127
|
+
handle_response(response, 'create order')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Get an order by ID or external_id (includes line items)
|
|
131
|
+
#
|
|
132
|
+
# @param order_id [String] Order UUID or external_id
|
|
133
|
+
# @param by_external_id [Boolean] If true, treat order_id as external_id
|
|
134
|
+
# @return [Hash] Order object with order_line_items
|
|
135
|
+
def get(order_id, by_external_id: false)
|
|
136
|
+
uri = URI("#{@base_url}/functions/v1/api-orders")
|
|
137
|
+
params = by_external_id ? { external_id: order_id } : { id: order_id }
|
|
138
|
+
uri.query = URI.encode_www_form(params)
|
|
139
|
+
|
|
140
|
+
response = make_request(:get, uri)
|
|
141
|
+
handle_response(response, 'get order')
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Update an order
|
|
145
|
+
#
|
|
146
|
+
# @param order_id [String] Order UUID
|
|
147
|
+
# @param name [String, nil] New name
|
|
148
|
+
# @param billing_contact_id [String, nil] New billing contact ID
|
|
149
|
+
# @param start_date [String, nil] New start date
|
|
150
|
+
# @param end_date [String, nil] New end date
|
|
151
|
+
# @param status [String, nil] New status
|
|
152
|
+
# @param currency [String, nil] New currency
|
|
153
|
+
# @param metadata [Hash, nil] New metadata (replaces existing)
|
|
154
|
+
# @return [Hash] Updated order object
|
|
155
|
+
def update(order_id, name: nil, billing_contact_id: nil, start_date: nil,
|
|
156
|
+
end_date: nil, status: nil, currency: nil, metadata: nil)
|
|
157
|
+
uri = URI("#{@base_url}/functions/v1/api-orders")
|
|
158
|
+
|
|
159
|
+
payload = { id: order_id }
|
|
160
|
+
payload[:name] = name if name
|
|
161
|
+
payload[:billing_contact_id] = billing_contact_id if billing_contact_id
|
|
162
|
+
payload[:start_date] = start_date if start_date
|
|
163
|
+
payload[:end_date] = end_date if end_date
|
|
164
|
+
payload[:status] = status if status
|
|
165
|
+
payload[:currency] = currency if currency
|
|
166
|
+
payload[:metadata] = metadata if metadata
|
|
167
|
+
|
|
168
|
+
response = make_request(:patch, uri, payload)
|
|
169
|
+
handle_response(response, 'update order')
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Delete an order and its line items
|
|
173
|
+
#
|
|
174
|
+
# @param order_id [String] Order UUID to delete
|
|
175
|
+
def delete(order_id)
|
|
176
|
+
uri = URI("#{@base_url}/functions/v1/api-orders")
|
|
177
|
+
payload = { id: order_id }
|
|
178
|
+
|
|
179
|
+
response = make_request(:delete, uri, payload)
|
|
180
|
+
handle_response(response, 'delete order')
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Add a line item to an order
|
|
184
|
+
#
|
|
185
|
+
# @param order_id [String] Order UUID (required)
|
|
186
|
+
# @param description [String] Line item description (required)
|
|
187
|
+
# @param quantity [Numeric] Quantity (required)
|
|
188
|
+
# @param unit_price [Numeric] Price per unit (required)
|
|
189
|
+
# @param product_id [String, nil] Product reference ID
|
|
190
|
+
# @param product_name [String, nil] Product name
|
|
191
|
+
# @param product_sku [String, nil] Product SKU
|
|
192
|
+
# @param usage_metric [String, nil] Usage metric name
|
|
193
|
+
# @param usage_quantity [Numeric, nil] Usage quantity
|
|
194
|
+
# @param discount_type [String, nil] "percentage" or "fixed"
|
|
195
|
+
# @param discount_value [Numeric, nil] Discount amount/percentage
|
|
196
|
+
# @param tax_rate [Numeric, nil] Tax rate percentage
|
|
197
|
+
# @param notes [String, nil] Additional notes
|
|
198
|
+
# @param sort_order [Integer, nil] Display order
|
|
199
|
+
# @param agent_id [String, nil] Associated agent ID
|
|
200
|
+
# @param item_type [String, nil] Type: usage, platform_fee, setup_fee, base_fee, overage, custom, product, service, subscription
|
|
201
|
+
# @param metadata [Hash, nil] Additional metadata
|
|
202
|
+
# @return [Hash] Created line item object
|
|
203
|
+
def add_line_item(order_id:, description:, quantity:, unit_price:, product_id: nil,
|
|
204
|
+
product_name: nil, product_sku: nil, usage_metric: nil, usage_quantity: nil,
|
|
205
|
+
discount_type: nil, discount_value: nil, tax_rate: nil, notes: nil,
|
|
206
|
+
sort_order: nil, agent_id: nil, item_type: nil, metadata: nil)
|
|
207
|
+
uri = URI("#{@base_url}/functions/v1/api-orders?action=line-items")
|
|
208
|
+
|
|
209
|
+
payload = {
|
|
210
|
+
order_id: order_id,
|
|
211
|
+
description: description,
|
|
212
|
+
quantity: quantity,
|
|
213
|
+
unit_price: unit_price
|
|
214
|
+
}
|
|
215
|
+
payload[:product_id] = product_id if product_id
|
|
216
|
+
payload[:product_name] = product_name if product_name
|
|
217
|
+
payload[:product_sku] = product_sku if product_sku
|
|
218
|
+
payload[:usage_metric] = usage_metric if usage_metric
|
|
219
|
+
payload[:usage_quantity] = usage_quantity if usage_quantity
|
|
220
|
+
payload[:discount_type] = discount_type if discount_type
|
|
221
|
+
payload[:discount_value] = discount_value if discount_value
|
|
222
|
+
payload[:tax_rate] = tax_rate if tax_rate
|
|
223
|
+
payload[:notes] = notes if notes
|
|
224
|
+
payload[:sort_order] = sort_order if sort_order
|
|
225
|
+
payload[:agent_id] = agent_id if agent_id
|
|
226
|
+
payload[:item_type] = item_type if item_type
|
|
227
|
+
payload[:metadata] = metadata if metadata
|
|
228
|
+
|
|
229
|
+
response = make_request(:post, uri, payload)
|
|
230
|
+
handle_response(response, 'add line item')
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Remove a line item from an order
|
|
234
|
+
#
|
|
235
|
+
# @param line_item_id [String] Line item UUID to delete
|
|
236
|
+
def remove_line_item(line_item_id)
|
|
237
|
+
uri = URI("#{@base_url}/functions/v1/api-orders?action=line-items")
|
|
238
|
+
payload = { id: line_item_id }
|
|
239
|
+
|
|
240
|
+
response = make_request(:delete, uri, payload)
|
|
241
|
+
handle_response(response, 'remove line item')
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
def make_request(method, uri, payload = nil)
|
|
247
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
248
|
+
http.use_ssl = true
|
|
249
|
+
http.read_timeout = 30
|
|
250
|
+
|
|
251
|
+
case method
|
|
252
|
+
when :get
|
|
253
|
+
request = Net::HTTP::Get.new(uri)
|
|
254
|
+
when :post
|
|
255
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
256
|
+
request.body = payload.to_json if payload
|
|
257
|
+
when :patch
|
|
258
|
+
request = Net::HTTP::Patch.new(uri.path)
|
|
259
|
+
request.body = payload.to_json if payload
|
|
260
|
+
when :delete
|
|
261
|
+
request = Net::HTTP::Delete.new(uri.request_uri)
|
|
262
|
+
request.body = payload.to_json if payload
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
request['Content-Type'] = 'application/json'
|
|
266
|
+
request['X-API-Key'] = @api_key
|
|
267
|
+
|
|
268
|
+
http.request(request)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def handle_response(response, operation)
|
|
272
|
+
body = response.body ? JSON.parse(response.body) : {}
|
|
273
|
+
|
|
274
|
+
unless %w[200 201 204].include?(response.code)
|
|
275
|
+
error_msg = body['error'] || "Failed to #{operation}"
|
|
276
|
+
raise error_msg
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
puts "[AgentBill] #{operation} successful" if @debug
|
|
280
|
+
body
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|