xero-kiwi 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/.env.example +2 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +89 -0
- data/Rakefile +89 -0
- data/docs/accounting/address.md +54 -0
- data/docs/accounting/branding-theme.md +92 -0
- data/docs/accounting/contact-group.md +91 -0
- data/docs/accounting/contact.md +166 -0
- data/docs/accounting/credit-note.md +97 -0
- data/docs/accounting/external-link.md +33 -0
- data/docs/accounting/invoice.md +134 -0
- data/docs/accounting/organisation.md +119 -0
- data/docs/accounting/overpayment.md +94 -0
- data/docs/accounting/payment-terms.md +58 -0
- data/docs/accounting/payment.md +99 -0
- data/docs/accounting/phone.md +45 -0
- data/docs/accounting/prepayment.md +111 -0
- data/docs/accounting/user.md +109 -0
- data/docs/client.md +174 -0
- data/docs/connections.md +166 -0
- data/docs/errors.md +271 -0
- data/docs/getting-started.md +138 -0
- data/docs/oauth.md +508 -0
- data/docs/retries-and-rate-limits.md +224 -0
- data/docs/tokens.md +339 -0
- data/lib/xero_kiwi/accounting/address.rb +58 -0
- data/lib/xero_kiwi/accounting/allocation.rb +66 -0
- data/lib/xero_kiwi/accounting/branding_theme.rb +76 -0
- data/lib/xero_kiwi/accounting/contact.rb +153 -0
- data/lib/xero_kiwi/accounting/contact_group.rb +57 -0
- data/lib/xero_kiwi/accounting/contact_person.rb +45 -0
- data/lib/xero_kiwi/accounting/credit_note.rb +115 -0
- data/lib/xero_kiwi/accounting/external_link.rb +38 -0
- data/lib/xero_kiwi/accounting/invoice.rb +142 -0
- data/lib/xero_kiwi/accounting/line_item.rb +64 -0
- data/lib/xero_kiwi/accounting/organisation.rb +138 -0
- data/lib/xero_kiwi/accounting/overpayment.rb +107 -0
- data/lib/xero_kiwi/accounting/payment.rb +105 -0
- data/lib/xero_kiwi/accounting/payment_terms.rb +77 -0
- data/lib/xero_kiwi/accounting/phone.rb +46 -0
- data/lib/xero_kiwi/accounting/prepayment.rb +109 -0
- data/lib/xero_kiwi/accounting/tracking_category.rb +42 -0
- data/lib/xero_kiwi/accounting/user.rb +80 -0
- data/lib/xero_kiwi/client.rb +576 -0
- data/lib/xero_kiwi/connection.rb +78 -0
- data/lib/xero_kiwi/errors.rb +34 -0
- data/lib/xero_kiwi/identity.rb +40 -0
- data/lib/xero_kiwi/oauth/id_token.rb +102 -0
- data/lib/xero_kiwi/oauth/pkce.rb +51 -0
- data/lib/xero_kiwi/oauth.rb +232 -0
- data/lib/xero_kiwi/token.rb +99 -0
- data/lib/xero_kiwi/token_refresher.rb +53 -0
- data/lib/xero_kiwi/version.rb +5 -0
- data/lib/xero_kiwi.rb +33 -0
- data/llms-full.txt +3351 -0
- data/llms.txt +56 -0
- data/sig/xero_kiwi.rbs +4 -0
- metadata +164 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module XeroKiwi
|
|
6
|
+
module Accounting
|
|
7
|
+
# Represents a Xero User returned by the Accounting API.
|
|
8
|
+
#
|
|
9
|
+
# See: https://developer.xero.com/documentation/api/accounting/users
|
|
10
|
+
class User
|
|
11
|
+
ATTRIBUTES = {
|
|
12
|
+
user_id: "UserID",
|
|
13
|
+
email_address: "EmailAddress",
|
|
14
|
+
first_name: "FirstName",
|
|
15
|
+
last_name: "LastName",
|
|
16
|
+
updated_date_utc: "UpdatedDateUTC",
|
|
17
|
+
is_subscriber: "IsSubscriber",
|
|
18
|
+
organisation_role: "OrganisationRole"
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
attr_reader(*ATTRIBUTES.keys)
|
|
22
|
+
|
|
23
|
+
def self.from_response(payload)
|
|
24
|
+
return [] if payload.nil?
|
|
25
|
+
|
|
26
|
+
items = payload["Users"]
|
|
27
|
+
return [] if items.nil?
|
|
28
|
+
|
|
29
|
+
items.map { |attrs| new(attrs) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(attrs)
|
|
33
|
+
attrs = attrs.transform_keys(&:to_s)
|
|
34
|
+
@user_id = attrs["UserID"]
|
|
35
|
+
@email_address = attrs["EmailAddress"]
|
|
36
|
+
@first_name = attrs["FirstName"]
|
|
37
|
+
@last_name = attrs["LastName"]
|
|
38
|
+
@updated_date_utc = parse_time(attrs["UpdatedDateUTC"])
|
|
39
|
+
@is_subscriber = attrs["IsSubscriber"]
|
|
40
|
+
@organisation_role = attrs["OrganisationRole"]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def subscriber? = is_subscriber == true
|
|
44
|
+
|
|
45
|
+
def to_h
|
|
46
|
+
ATTRIBUTES.keys.to_h { |key| [key, public_send(key)] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def ==(other)
|
|
50
|
+
other.is_a?(User) && other.user_id == user_id
|
|
51
|
+
end
|
|
52
|
+
alias eql? ==
|
|
53
|
+
|
|
54
|
+
def hash = [self.class, user_id].hash
|
|
55
|
+
|
|
56
|
+
def inspect
|
|
57
|
+
"#<#{self.class} user_id=#{user_id.inspect} " \
|
|
58
|
+
"email_address=#{email_address.inspect} organisation_role=#{organisation_role.inspect}>"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def parse_time(value)
|
|
64
|
+
return nil if value.nil?
|
|
65
|
+
|
|
66
|
+
str = value.to_s.strip
|
|
67
|
+
return nil if str.empty?
|
|
68
|
+
|
|
69
|
+
if (match = str.match(%r{\A/Date\((\d+)([+-]\d{4})?\)/\z}))
|
|
70
|
+
Time.at(match[1].to_i / 1000.0).utc
|
|
71
|
+
else
|
|
72
|
+
str = "#{str}Z" unless str.match?(/[Zz]\z|[+-]\d{2}:?\d{2}\z/)
|
|
73
|
+
Time.iso8601(str)
|
|
74
|
+
end
|
|
75
|
+
rescue ArgumentError
|
|
76
|
+
nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "faraday/retry"
|
|
5
|
+
|
|
6
|
+
module XeroKiwi
|
|
7
|
+
# Entry point for talking to Xero. Holds the OAuth2 token state, knows how
|
|
8
|
+
# to refresh it (when given client credentials), and exposes resource
|
|
9
|
+
# methods that auto-refresh before each request.
|
|
10
|
+
#
|
|
11
|
+
# # Simple — access token only, no refresh capability.
|
|
12
|
+
# client = XeroKiwi::Client.new(access_token: "ya29...")
|
|
13
|
+
#
|
|
14
|
+
# # Full — refresh-capable, with persistence callback.
|
|
15
|
+
# client = XeroKiwi::Client.new(
|
|
16
|
+
# access_token: creds.access_token,
|
|
17
|
+
# refresh_token: creds.refresh_token,
|
|
18
|
+
# expires_at: creds.expires_at,
|
|
19
|
+
# client_id: ENV["XERO_CLIENT_ID"],
|
|
20
|
+
# client_secret: ENV["XERO_CLIENT_SECRET"],
|
|
21
|
+
# on_token_refresh: ->(token) { creds.update!(token.to_h) }
|
|
22
|
+
# )
|
|
23
|
+
#
|
|
24
|
+
# client.token # => XeroKiwi::Token
|
|
25
|
+
# client.token.expired? # => false
|
|
26
|
+
# client.refresh_token! # manual force refresh
|
|
27
|
+
# client.connections # auto-refreshes if expiring; reactive on 401
|
|
28
|
+
class Client
|
|
29
|
+
BASE_URL = "https://api.xero.com"
|
|
30
|
+
DEFAULT_USER_AGENT = "XeroKiwi/#{XeroKiwi::VERSION} (+https://github.com/douglasgreyling/xero-kiwi)".freeze
|
|
31
|
+
|
|
32
|
+
# HTTP statuses we treat as transient. faraday-retry honours Retry-After
|
|
33
|
+
# automatically when the status is in this list.
|
|
34
|
+
RETRY_STATUSES = [429, 502, 503, 504].freeze
|
|
35
|
+
|
|
36
|
+
DEFAULT_RETRY_OPTIONS = {
|
|
37
|
+
max: 4,
|
|
38
|
+
interval: 0.5,
|
|
39
|
+
interval_randomness: 0.5,
|
|
40
|
+
backoff_factor: 2,
|
|
41
|
+
retry_statuses: RETRY_STATUSES,
|
|
42
|
+
methods: %i[get head options put delete post],
|
|
43
|
+
# Faraday::RetriableResponse is the *internal* signal faraday-retry uses
|
|
44
|
+
# to flag a status-code retry. It MUST be in this list, or the middleware
|
|
45
|
+
# can't catch its own retry signal and 429s/503s never get retried.
|
|
46
|
+
exceptions: [
|
|
47
|
+
Faraday::ConnectionFailed,
|
|
48
|
+
Faraday::TimeoutError,
|
|
49
|
+
Faraday::RetriableResponse,
|
|
50
|
+
Errno::ETIMEDOUT
|
|
51
|
+
]
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
attr_reader :token
|
|
55
|
+
|
|
56
|
+
def initialize(
|
|
57
|
+
access_token:,
|
|
58
|
+
refresh_token: nil,
|
|
59
|
+
expires_at: nil,
|
|
60
|
+
client_id: nil,
|
|
61
|
+
client_secret: nil,
|
|
62
|
+
on_token_refresh: nil,
|
|
63
|
+
adapter: nil,
|
|
64
|
+
user_agent: DEFAULT_USER_AGENT,
|
|
65
|
+
retry_options: {}
|
|
66
|
+
)
|
|
67
|
+
@token = Token.new(
|
|
68
|
+
access_token: access_token,
|
|
69
|
+
refresh_token: refresh_token,
|
|
70
|
+
expires_at: expires_at
|
|
71
|
+
)
|
|
72
|
+
@client_id = client_id
|
|
73
|
+
@client_secret = client_secret
|
|
74
|
+
@on_token_refresh = on_token_refresh
|
|
75
|
+
@adapter = adapter
|
|
76
|
+
@user_agent = user_agent
|
|
77
|
+
@retry_options = DEFAULT_RETRY_OPTIONS.merge(retry_options)
|
|
78
|
+
@refresh_mutex = Mutex.new
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Fetches the list of tenants the current access token has access to.
|
|
82
|
+
# See: https://developer.xero.com/documentation/best-practices/managing-connections/connections
|
|
83
|
+
def connections
|
|
84
|
+
with_authenticated_request do
|
|
85
|
+
response = http.get("/connections")
|
|
86
|
+
Connection.from_response(response.body)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Fetches the Organisation for the given tenant. Accepts a tenant-id
|
|
91
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
92
|
+
# See: https://developer.xero.com/documentation/api/accounting/organisation
|
|
93
|
+
def organisation(tenant_id)
|
|
94
|
+
tid = extract_tenant_id(tenant_id)
|
|
95
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
96
|
+
|
|
97
|
+
with_authenticated_request do
|
|
98
|
+
response = http.get("/api.xro/2.0/Organisation") do |req|
|
|
99
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
100
|
+
end
|
|
101
|
+
Accounting::Organisation.from_response(response.body)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Fetches the Users for the given tenant. Accepts a tenant-id
|
|
106
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
107
|
+
# See: https://developer.xero.com/documentation/api/accounting/users
|
|
108
|
+
def users(tenant_id)
|
|
109
|
+
tid = extract_tenant_id(tenant_id)
|
|
110
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
111
|
+
|
|
112
|
+
with_authenticated_request do
|
|
113
|
+
response = http.get("/api.xro/2.0/Users") do |req|
|
|
114
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
115
|
+
end
|
|
116
|
+
Accounting::User.from_response(response.body)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Fetches a single User by ID for the given tenant. Accepts a tenant-id
|
|
121
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
122
|
+
# See: https://developer.xero.com/documentation/api/accounting/users
|
|
123
|
+
def user(tenant_id, user_id)
|
|
124
|
+
tid = extract_tenant_id(tenant_id)
|
|
125
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
126
|
+
raise ArgumentError, "user_id is required" if user_id.nil? || user_id.to_s.empty?
|
|
127
|
+
|
|
128
|
+
with_authenticated_request do
|
|
129
|
+
response = http.get("/api.xro/2.0/Users/#{user_id}") do |req|
|
|
130
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
131
|
+
end
|
|
132
|
+
Accounting::User.from_response(response.body).first
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Fetches the Contacts for the given tenant. Accepts a tenant-id
|
|
137
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
138
|
+
# See: https://developer.xero.com/documentation/api/accounting/contacts
|
|
139
|
+
def contacts(tenant_id)
|
|
140
|
+
tid = extract_tenant_id(tenant_id)
|
|
141
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
142
|
+
|
|
143
|
+
with_authenticated_request do
|
|
144
|
+
response = http.get("/api.xro/2.0/Contacts") do |req|
|
|
145
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
146
|
+
end
|
|
147
|
+
Accounting::Contact.from_response(response.body)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Fetches a single Contact by ID for the given tenant. Accepts a tenant-id
|
|
152
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
153
|
+
# See: https://developer.xero.com/documentation/api/accounting/contacts
|
|
154
|
+
def contact(tenant_id, contact_id)
|
|
155
|
+
tid = extract_tenant_id(tenant_id)
|
|
156
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
157
|
+
raise ArgumentError, "contact_id is required" if contact_id.nil? || contact_id.to_s.empty?
|
|
158
|
+
|
|
159
|
+
with_authenticated_request do
|
|
160
|
+
response = http.get("/api.xro/2.0/Contacts/#{contact_id}") do |req|
|
|
161
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
162
|
+
end
|
|
163
|
+
Accounting::Contact.from_response(response.body).first
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Fetches the Contact Groups for the given tenant. Accepts a tenant-id
|
|
168
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
169
|
+
# See: https://developer.xero.com/documentation/api/accounting/contactgroups
|
|
170
|
+
def contact_groups(tenant_id)
|
|
171
|
+
tid = extract_tenant_id(tenant_id)
|
|
172
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
173
|
+
|
|
174
|
+
with_authenticated_request do
|
|
175
|
+
response = http.get("/api.xro/2.0/ContactGroups") do |req|
|
|
176
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
177
|
+
end
|
|
178
|
+
Accounting::ContactGroup.from_response(response.body)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Fetches a single Contact Group by ID for the given tenant. Accepts a
|
|
183
|
+
# tenant-id string or a XeroKiwi::Connection (we use its tenant_id).
|
|
184
|
+
# See: https://developer.xero.com/documentation/api/accounting/contactgroups
|
|
185
|
+
def contact_group(tenant_id, contact_group_id)
|
|
186
|
+
tid = extract_tenant_id(tenant_id)
|
|
187
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
188
|
+
raise ArgumentError, "contact_group_id is required" if contact_group_id.nil? || contact_group_id.to_s.empty?
|
|
189
|
+
|
|
190
|
+
with_authenticated_request do
|
|
191
|
+
response = http.get("/api.xro/2.0/ContactGroups/#{contact_group_id}") do |req|
|
|
192
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
193
|
+
end
|
|
194
|
+
Accounting::ContactGroup.from_response(response.body).first
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Fetches the Prepayments for the given tenant. Accepts a tenant-id
|
|
199
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
200
|
+
# See: https://developer.xero.com/documentation/api/accounting/prepayments
|
|
201
|
+
def prepayments(tenant_id)
|
|
202
|
+
tid = extract_tenant_id(tenant_id)
|
|
203
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
204
|
+
|
|
205
|
+
with_authenticated_request do
|
|
206
|
+
response = http.get("/api.xro/2.0/Prepayments") do |req|
|
|
207
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
208
|
+
end
|
|
209
|
+
Accounting::Prepayment.from_response(response.body)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Fetches a single Prepayment by ID for the given tenant. Accepts a
|
|
214
|
+
# tenant-id string or a XeroKiwi::Connection (we use its tenant_id).
|
|
215
|
+
# See: https://developer.xero.com/documentation/api/accounting/prepayments
|
|
216
|
+
def prepayment(tenant_id, prepayment_id)
|
|
217
|
+
tid = extract_tenant_id(tenant_id)
|
|
218
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
219
|
+
raise ArgumentError, "prepayment_id is required" if prepayment_id.nil? || prepayment_id.to_s.empty?
|
|
220
|
+
|
|
221
|
+
with_authenticated_request do
|
|
222
|
+
response = http.get("/api.xro/2.0/Prepayments/#{prepayment_id}") do |req|
|
|
223
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
224
|
+
end
|
|
225
|
+
Accounting::Prepayment.from_response(response.body).first
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Fetches the Credit Notes for the given tenant. Accepts a tenant-id
|
|
230
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
231
|
+
# See: https://developer.xero.com/documentation/api/accounting/creditnotes
|
|
232
|
+
def credit_notes(tenant_id)
|
|
233
|
+
tid = extract_tenant_id(tenant_id)
|
|
234
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
235
|
+
|
|
236
|
+
with_authenticated_request do
|
|
237
|
+
response = http.get("/api.xro/2.0/CreditNotes") do |req|
|
|
238
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
239
|
+
end
|
|
240
|
+
Accounting::CreditNote.from_response(response.body)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Fetches a single Credit Note by ID for the given tenant. Accepts a
|
|
245
|
+
# tenant-id string or a XeroKiwi::Connection (we use its tenant_id).
|
|
246
|
+
# See: https://developer.xero.com/documentation/api/accounting/creditnotes
|
|
247
|
+
def credit_note(tenant_id, credit_note_id)
|
|
248
|
+
tid = extract_tenant_id(tenant_id)
|
|
249
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
250
|
+
raise ArgumentError, "credit_note_id is required" if credit_note_id.nil? || credit_note_id.to_s.empty?
|
|
251
|
+
|
|
252
|
+
with_authenticated_request do
|
|
253
|
+
response = http.get("/api.xro/2.0/CreditNotes/#{credit_note_id}") do |req|
|
|
254
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
255
|
+
end
|
|
256
|
+
Accounting::CreditNote.from_response(response.body).first
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Fetches the Overpayments for the given tenant. Accepts a tenant-id
|
|
261
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
262
|
+
# See: https://developer.xero.com/documentation/api/accounting/overpayments
|
|
263
|
+
def overpayments(tenant_id)
|
|
264
|
+
tid = extract_tenant_id(tenant_id)
|
|
265
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
266
|
+
|
|
267
|
+
with_authenticated_request do
|
|
268
|
+
response = http.get("/api.xro/2.0/Overpayments") do |req|
|
|
269
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
270
|
+
end
|
|
271
|
+
Accounting::Overpayment.from_response(response.body)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Fetches a single Overpayment by ID for the given tenant. Accepts a
|
|
276
|
+
# tenant-id string or a XeroKiwi::Connection (we use its tenant_id).
|
|
277
|
+
# See: https://developer.xero.com/documentation/api/accounting/overpayments
|
|
278
|
+
def overpayment(tenant_id, overpayment_id)
|
|
279
|
+
tid = extract_tenant_id(tenant_id)
|
|
280
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
281
|
+
raise ArgumentError, "overpayment_id is required" if overpayment_id.nil? || overpayment_id.to_s.empty?
|
|
282
|
+
|
|
283
|
+
with_authenticated_request do
|
|
284
|
+
response = http.get("/api.xro/2.0/Overpayments/#{overpayment_id}") do |req|
|
|
285
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
286
|
+
end
|
|
287
|
+
Accounting::Overpayment.from_response(response.body).first
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Fetches the Payments for the given tenant. Accepts a tenant-id
|
|
292
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
293
|
+
# See: https://developer.xero.com/documentation/api/accounting/payments
|
|
294
|
+
def payments(tenant_id)
|
|
295
|
+
tid = extract_tenant_id(tenant_id)
|
|
296
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
297
|
+
|
|
298
|
+
with_authenticated_request do
|
|
299
|
+
response = http.get("/api.xro/2.0/Payments") do |req|
|
|
300
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
301
|
+
end
|
|
302
|
+
Accounting::Payment.from_response(response.body)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Fetches a single Payment by ID for the given tenant. Accepts a
|
|
307
|
+
# tenant-id string or a XeroKiwi::Connection (we use its tenant_id).
|
|
308
|
+
# See: https://developer.xero.com/documentation/api/accounting/payments
|
|
309
|
+
def payment(tenant_id, payment_id)
|
|
310
|
+
tid = extract_tenant_id(tenant_id)
|
|
311
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
312
|
+
raise ArgumentError, "payment_id is required" if payment_id.nil? || payment_id.to_s.empty?
|
|
313
|
+
|
|
314
|
+
with_authenticated_request do
|
|
315
|
+
response = http.get("/api.xro/2.0/Payments/#{payment_id}") do |req|
|
|
316
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
317
|
+
end
|
|
318
|
+
Accounting::Payment.from_response(response.body).first
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Fetches the Invoices for the given tenant. Accepts a tenant-id
|
|
323
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
324
|
+
# See: https://developer.xero.com/documentation/api/accounting/invoices
|
|
325
|
+
def invoices(tenant_id)
|
|
326
|
+
tid = extract_tenant_id(tenant_id)
|
|
327
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
328
|
+
|
|
329
|
+
with_authenticated_request do
|
|
330
|
+
response = http.get("/api.xro/2.0/Invoices") do |req|
|
|
331
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
332
|
+
end
|
|
333
|
+
Accounting::Invoice.from_response(response.body)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Fetches a single Invoice by ID for the given tenant. Accepts a
|
|
338
|
+
# tenant-id string or a XeroKiwi::Connection (we use its tenant_id).
|
|
339
|
+
# See: https://developer.xero.com/documentation/api/accounting/invoices
|
|
340
|
+
def invoice(tenant_id, invoice_id)
|
|
341
|
+
tid = extract_tenant_id(tenant_id)
|
|
342
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
343
|
+
raise ArgumentError, "invoice_id is required" if invoice_id.nil? || invoice_id.to_s.empty?
|
|
344
|
+
|
|
345
|
+
with_authenticated_request do
|
|
346
|
+
response = http.get("/api.xro/2.0/Invoices/#{invoice_id}") do |req|
|
|
347
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
348
|
+
end
|
|
349
|
+
Accounting::Invoice.from_response(response.body).first
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Fetches the online invoice URL for a sales (ACCREC) invoice. Returns
|
|
354
|
+
# the URL string, or nil if not available. Cannot be used on DRAFT invoices.
|
|
355
|
+
# See: https://developer.xero.com/documentation/api/accounting/invoices
|
|
356
|
+
def online_invoice_url(tenant_id, invoice_id)
|
|
357
|
+
tid = extract_tenant_id(tenant_id)
|
|
358
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
359
|
+
raise ArgumentError, "invoice_id is required" if invoice_id.nil? || invoice_id.to_s.empty?
|
|
360
|
+
|
|
361
|
+
data = with_authenticated_request do
|
|
362
|
+
http.get("/api.xro/2.0/Invoices/#{invoice_id}/OnlineInvoice") do |req|
|
|
363
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
data.body.dig("OnlineInvoices", 0, "OnlineInvoiceUrl")
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Fetches the Branding Themes for the given tenant. Accepts a tenant-id
|
|
370
|
+
# string or a XeroKiwi::Connection (we use its tenant_id).
|
|
371
|
+
# See: https://developer.xero.com/documentation/api/accounting/brandingthemes
|
|
372
|
+
def branding_themes(tenant_id)
|
|
373
|
+
tid = extract_tenant_id(tenant_id)
|
|
374
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
375
|
+
|
|
376
|
+
with_authenticated_request do
|
|
377
|
+
response = http.get("/api.xro/2.0/BrandingThemes") do |req|
|
|
378
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
379
|
+
end
|
|
380
|
+
Accounting::BrandingTheme.from_response(response.body)
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Fetches a single Branding Theme by ID for the given tenant. Accepts a
|
|
385
|
+
# tenant-id string or a XeroKiwi::Connection (we use its tenant_id).
|
|
386
|
+
# See: https://developer.xero.com/documentation/api/accounting/brandingthemes
|
|
387
|
+
def branding_theme(tenant_id, branding_theme_id)
|
|
388
|
+
tid = extract_tenant_id(tenant_id)
|
|
389
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
390
|
+
raise ArgumentError, "branding_theme_id is required" if branding_theme_id.nil? || branding_theme_id.to_s.empty?
|
|
391
|
+
|
|
392
|
+
with_authenticated_request do
|
|
393
|
+
response = http.get("/api.xro/2.0/BrandingThemes/#{branding_theme_id}") do |req|
|
|
394
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
395
|
+
end
|
|
396
|
+
Accounting::BrandingTheme.from_response(response.body).first
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Disconnects a tenant. Accepts either a XeroKiwi::Connection (we use its
|
|
401
|
+
# `id`) or a raw connection-id string. Returns true on the 204. The
|
|
402
|
+
# access token may still be valid for *other* connections after this —
|
|
403
|
+
# only the named tenant is detached.
|
|
404
|
+
def delete_connection(connection_or_id)
|
|
405
|
+
id = extract_connection_id(connection_or_id)
|
|
406
|
+
raise ArgumentError, "connection id is required" if id.nil? || id.empty?
|
|
407
|
+
|
|
408
|
+
with_authenticated_request do
|
|
409
|
+
http.delete("/connections/#{id}")
|
|
410
|
+
true
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Revokes the current refresh token at Xero, invalidating it and every
|
|
415
|
+
# access token issued from it. Use this for "disconnect Xero" / logout
|
|
416
|
+
# flows. After this call, treat the client as dead — subsequent API
|
|
417
|
+
# calls will 401. The caller is responsible for cleaning up any
|
|
418
|
+
# persisted credential record.
|
|
419
|
+
def revoke_token!
|
|
420
|
+
raise TokenRefreshError.new(nil, nil, "client has no refresh capability") unless can_refresh?
|
|
421
|
+
|
|
422
|
+
revoker.revoke_token(refresh_token: @token.refresh_token)
|
|
423
|
+
true
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Forces a refresh regardless of expiry. Returns the new Token. Raises
|
|
427
|
+
# TokenRefreshError if refresh credentials are missing or if Xero rejects
|
|
428
|
+
# the refresh.
|
|
429
|
+
def refresh_token!
|
|
430
|
+
raise TokenRefreshError.new(nil, nil, "client has no refresh capability") unless can_refresh?
|
|
431
|
+
|
|
432
|
+
@refresh_mutex.synchronize { perform_refresh }
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# True if this client was constructed with refresh credentials AND the
|
|
436
|
+
# current token still carries a refresh_token to use.
|
|
437
|
+
def can_refresh?
|
|
438
|
+
!@client_id.nil? && !@client_secret.nil? && @token.refreshable?
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
private
|
|
442
|
+
|
|
443
|
+
# Wraps each API call with proactive + reactive token refresh:
|
|
444
|
+
#
|
|
445
|
+
# - Proactive: if the current token is expiring within the default window,
|
|
446
|
+
# refresh BEFORE the request fires. This covers the common case.
|
|
447
|
+
# - Reactive: if the request still 401s (e.g. our clock drifted, or Xero
|
|
448
|
+
# revoked the token early), refresh and retry exactly once. The `retried`
|
|
449
|
+
# flag prevents an infinite loop.
|
|
450
|
+
def with_authenticated_request
|
|
451
|
+
ensure_fresh_token!
|
|
452
|
+
retried = false
|
|
453
|
+
begin
|
|
454
|
+
yield
|
|
455
|
+
rescue AuthenticationError
|
|
456
|
+
raise if retried || !can_refresh?
|
|
457
|
+
|
|
458
|
+
retried = true
|
|
459
|
+
refresh_token!
|
|
460
|
+
retry
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# Auto-refresh path. Cheap to call before every request: only takes the
|
|
465
|
+
# mutex if the token is actually expiring, then double-checks inside the
|
|
466
|
+
# mutex to dedupe concurrent refreshes from different threads.
|
|
467
|
+
def ensure_fresh_token!
|
|
468
|
+
return unless can_refresh?
|
|
469
|
+
return unless @token.expiring_soon?
|
|
470
|
+
|
|
471
|
+
@refresh_mutex.synchronize do
|
|
472
|
+
perform_refresh if @token.expiring_soon?
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# The actual refresh round-trip. Always called inside @refresh_mutex by
|
|
477
|
+
# the two callers above. Mutating @token and the Faraday Authorization
|
|
478
|
+
# header is the only place we touch shared state.
|
|
479
|
+
def perform_refresh
|
|
480
|
+
new_token = refresher.refresh(refresh_token: @token.refresh_token)
|
|
481
|
+
@token = new_token
|
|
482
|
+
@_http&.headers&.[]=("Authorization", "Bearer #{new_token.access_token}")
|
|
483
|
+
@on_token_refresh&.call(new_token)
|
|
484
|
+
new_token
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def refresher
|
|
488
|
+
@_refresher ||= TokenRefresher.new(
|
|
489
|
+
client_id: @client_id,
|
|
490
|
+
client_secret: @client_secret,
|
|
491
|
+
adapter: @adapter
|
|
492
|
+
)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Lightweight OAuth instance used solely for token revocation. We don't
|
|
496
|
+
# need a redirect_uri for /connect/revocation, so this is constructed
|
|
497
|
+
# without one. Built lazily so a Client that never revokes pays nothing.
|
|
498
|
+
def revoker
|
|
499
|
+
@_revoker ||= OAuth.new(
|
|
500
|
+
client_id: @client_id,
|
|
501
|
+
client_secret: @client_secret,
|
|
502
|
+
adapter: @adapter
|
|
503
|
+
)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
def extract_connection_id(value)
|
|
507
|
+
value.is_a?(Connection) ? value.id : value
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def extract_tenant_id(value)
|
|
511
|
+
value.is_a?(Connection) ? value.tenant_id : value
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def http
|
|
515
|
+
@_http ||= build_http
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Middleware order matters. Outbound runs top-to-bottom; inbound runs in
|
|
519
|
+
# reverse. We want:
|
|
520
|
+
#
|
|
521
|
+
# 1. ResponseHandler (outermost) — converts the FINAL response status into
|
|
522
|
+
# a XeroKiwi exception, *after* retries have been exhausted.
|
|
523
|
+
# 2. Retry — retries on 429/503 (respecting Retry-After) and on transport
|
|
524
|
+
# exceptions.
|
|
525
|
+
# 3. JSON — parses the response body so handlers downstream get a Hash.
|
|
526
|
+
# 4. Adapter — actually makes the HTTP call.
|
|
527
|
+
#
|
|
528
|
+
# Putting ResponseHandler outside Retry is the key trick: it means a 429
|
|
529
|
+
# gets retried by Faraday before we ever raise RateLimitError, and the
|
|
530
|
+
# exception only fires once we've truly given up.
|
|
531
|
+
def build_http
|
|
532
|
+
Faraday.new(url: BASE_URL) do |f|
|
|
533
|
+
f.use ResponseHandler
|
|
534
|
+
f.request :retry, @retry_options
|
|
535
|
+
f.response :json, content_type: /\bjson/
|
|
536
|
+
f.adapter(@adapter || Faraday.default_adapter)
|
|
537
|
+
|
|
538
|
+
f.headers["Authorization"] = "Bearer #{@token.access_token}"
|
|
539
|
+
f.headers["Accept"] = "application/json"
|
|
540
|
+
f.headers["User-Agent"] = @user_agent
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Faraday middleware that maps non-2xx responses onto our exception
|
|
545
|
+
# hierarchy. Lives outside the retry middleware so it only fires on the
|
|
546
|
+
# final response.
|
|
547
|
+
class ResponseHandler < Faraday::Middleware
|
|
548
|
+
def on_complete(env)
|
|
549
|
+
return if (200..299).cover?(env.status)
|
|
550
|
+
|
|
551
|
+
raise error_for(env)
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
private
|
|
555
|
+
|
|
556
|
+
def error_for(env)
|
|
557
|
+
case env.status
|
|
558
|
+
when 401 then AuthenticationError.new(env.status, env.body)
|
|
559
|
+
when 429 then rate_limit_error(env)
|
|
560
|
+
when 400..499 then ClientError.new(env.status, env.body)
|
|
561
|
+
when 500..599 then ServerError.new(env.status, env.body)
|
|
562
|
+
else APIError.new(env.status, env.body)
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def rate_limit_error(env)
|
|
567
|
+
RateLimitError.new(
|
|
568
|
+
env.status,
|
|
569
|
+
env.body,
|
|
570
|
+
retry_after: env.response_headers["retry-after"]&.to_f,
|
|
571
|
+
problem: env.response_headers["x-rate-limit-problem"]
|
|
572
|
+
)
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
end
|