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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.env.example +2 -0
  3. data/CHANGELOG.md +5 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +89 -0
  6. data/Rakefile +89 -0
  7. data/docs/accounting/address.md +54 -0
  8. data/docs/accounting/branding-theme.md +92 -0
  9. data/docs/accounting/contact-group.md +91 -0
  10. data/docs/accounting/contact.md +166 -0
  11. data/docs/accounting/credit-note.md +97 -0
  12. data/docs/accounting/external-link.md +33 -0
  13. data/docs/accounting/invoice.md +134 -0
  14. data/docs/accounting/organisation.md +119 -0
  15. data/docs/accounting/overpayment.md +94 -0
  16. data/docs/accounting/payment-terms.md +58 -0
  17. data/docs/accounting/payment.md +99 -0
  18. data/docs/accounting/phone.md +45 -0
  19. data/docs/accounting/prepayment.md +111 -0
  20. data/docs/accounting/user.md +109 -0
  21. data/docs/client.md +174 -0
  22. data/docs/connections.md +166 -0
  23. data/docs/errors.md +271 -0
  24. data/docs/getting-started.md +138 -0
  25. data/docs/oauth.md +508 -0
  26. data/docs/retries-and-rate-limits.md +224 -0
  27. data/docs/tokens.md +339 -0
  28. data/lib/xero_kiwi/accounting/address.rb +58 -0
  29. data/lib/xero_kiwi/accounting/allocation.rb +66 -0
  30. data/lib/xero_kiwi/accounting/branding_theme.rb +76 -0
  31. data/lib/xero_kiwi/accounting/contact.rb +153 -0
  32. data/lib/xero_kiwi/accounting/contact_group.rb +57 -0
  33. data/lib/xero_kiwi/accounting/contact_person.rb +45 -0
  34. data/lib/xero_kiwi/accounting/credit_note.rb +115 -0
  35. data/lib/xero_kiwi/accounting/external_link.rb +38 -0
  36. data/lib/xero_kiwi/accounting/invoice.rb +142 -0
  37. data/lib/xero_kiwi/accounting/line_item.rb +64 -0
  38. data/lib/xero_kiwi/accounting/organisation.rb +138 -0
  39. data/lib/xero_kiwi/accounting/overpayment.rb +107 -0
  40. data/lib/xero_kiwi/accounting/payment.rb +105 -0
  41. data/lib/xero_kiwi/accounting/payment_terms.rb +77 -0
  42. data/lib/xero_kiwi/accounting/phone.rb +46 -0
  43. data/lib/xero_kiwi/accounting/prepayment.rb +109 -0
  44. data/lib/xero_kiwi/accounting/tracking_category.rb +42 -0
  45. data/lib/xero_kiwi/accounting/user.rb +80 -0
  46. data/lib/xero_kiwi/client.rb +576 -0
  47. data/lib/xero_kiwi/connection.rb +78 -0
  48. data/lib/xero_kiwi/errors.rb +34 -0
  49. data/lib/xero_kiwi/identity.rb +40 -0
  50. data/lib/xero_kiwi/oauth/id_token.rb +102 -0
  51. data/lib/xero_kiwi/oauth/pkce.rb +51 -0
  52. data/lib/xero_kiwi/oauth.rb +232 -0
  53. data/lib/xero_kiwi/token.rb +99 -0
  54. data/lib/xero_kiwi/token_refresher.rb +53 -0
  55. data/lib/xero_kiwi/version.rb +5 -0
  56. data/lib/xero_kiwi.rb +33 -0
  57. data/llms-full.txt +3351 -0
  58. data/llms.txt +56 -0
  59. data/sig/xero_kiwi.rbs +4 -0
  60. 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