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
data/docs/errors.md ADDED
@@ -0,0 +1,271 @@
1
+ # Errors
2
+
3
+ Every failure path in XeroKiwi raises a typed exception. This page walks through
4
+ the hierarchy, explains what each class means, and tells you what to catch
5
+ in common situations.
6
+
7
+ ## The hierarchy
8
+
9
+ ```
10
+ StandardError
11
+ └─ XeroKiwi::Error (root — catch this for "anything XeroKiwi raised")
12
+ ├─ XeroKiwi::APIError (root for HTTP responses; carries status + body)
13
+ │ ├─ XeroKiwi::AuthenticationError (401)
14
+ │ │ ├─ XeroKiwi::TokenRefreshError (refresh round-trip failed)
15
+ │ │ └─ XeroKiwi::OAuth::CodeExchangeError (auth-code exchange failed)
16
+ │ ├─ XeroKiwi::ClientError (other 4xx)
17
+ │ ├─ XeroKiwi::ServerError (5xx)
18
+ │ └─ XeroKiwi::RateLimitError (429, with retry_after + problem)
19
+ ├─ XeroKiwi::OAuth::StateMismatchError (CSRF check failed)
20
+ └─ XeroKiwi::OAuth::IDTokenError (ID token JWT verification failed)
21
+ ```
22
+
23
+ A few things worth noticing about the shape:
24
+
25
+ - **Everything XeroKiwi raises** descends from `XeroKiwi::Error`. If you only want
26
+ one rescue clause for "the Xero integration broke," catch this.
27
+ - **HTTP responses** descend from `XeroKiwi::APIError`, which carries `status`
28
+ and `body` attributes you can inspect.
29
+ - **OAuth-specific errors** for state mismatch and ID token verification
30
+ descend from `XeroKiwi::Error` directly (not `APIError`) because they don't
31
+ come from an HTTP response — they're local validation failures.
32
+ - **`XeroKiwi::TokenRefreshError`** and **`XeroKiwi::OAuth::CodeExchangeError`** are
33
+ both `AuthenticationError` subclasses, because the practical recovery is
34
+ the same as for any 401: the user needs to re-authorise.
35
+
36
+ ## Class reference
37
+
38
+ ### `XeroKiwi::Error`
39
+
40
+ The root class. Inherits from `StandardError`. You almost never raise this
41
+ directly — it exists as a catch-all for code that wants to rescue "any XeroKiwi
42
+ problem" without enumerating every subclass.
43
+
44
+ ### `XeroKiwi::APIError`
45
+
46
+ The base class for HTTP errors. Constructor: `APIError.new(status, body, message = nil)`.
47
+
48
+ Attributes:
49
+
50
+ | Attribute | Type | What it is |
51
+ |-----------|------|------------|
52
+ | `error.status` | `Integer` | The HTTP status code (e.g. 401, 429, 500). |
53
+ | `error.body` | parsed body | The response body, parsed as JSON when the response was JSON, otherwise the raw string. |
54
+ | `error.message` | `String` | A descriptive message. Defaults to `"Xero API responded with #{status}: #{body.inspect}"`. |
55
+
56
+ ### `XeroKiwi::AuthenticationError` (HTTP 401)
57
+
58
+ The access token was rejected. The most common causes:
59
+
60
+ - Token has expired and there's no refresh capability.
61
+ - Token was revoked.
62
+ - Token has the wrong scopes for this endpoint.
63
+ - The wrong tenant ID was passed in the `Xero-Tenant-Id` header.
64
+
65
+ If your client has refresh capability, XeroKiwi will already have tried to
66
+ refresh and retry exactly once before this raises. Seeing `AuthenticationError`
67
+ on a refresh-capable client means **the second 401 also failed** — refresh
68
+ won't fix it, and you need to either re-authorise or surface the error.
69
+
70
+ ### `XeroKiwi::TokenRefreshError`
71
+
72
+ A subclass of `AuthenticationError`. Raised when the refresh round-trip
73
+ itself fails. Most common cause: **the refresh token has been rotated by
74
+ another process** (see [the rotation gotcha in the tokens
75
+ doc](tokens.md#refresh-token-rotation)).
76
+
77
+ This is different from "the user must re-auth" in subtle ways:
78
+
79
+ - **Refresh token rotated**: another process refreshed before you did.
80
+ Reload the credential from storage and retry.
81
+ - **Refresh token genuinely expired** (60 days unused): the user must
82
+ re-authorise.
83
+
84
+ You can't tell these apart from the exception alone — both look like
85
+ `invalid_grant` from Xero. The recovery pattern in
86
+ [Tokens — multi-process refresh](tokens.md#multi-process-refresh) handles
87
+ both cases.
88
+
89
+ ### `XeroKiwi::OAuth::CodeExchangeError`
90
+
91
+ Also a subclass of `AuthenticationError`. Raised when `oauth.exchange_code`
92
+ fails:
93
+
94
+ - The auth code expired (Xero's codes are very short-lived).
95
+ - The code was already used (codes are single-use).
96
+ - The `redirect_uri` doesn't match what was used at authorise time.
97
+ - The PKCE verifier doesn't match the challenge sent at authorise time.
98
+ - The client credentials are wrong.
99
+
100
+ The user needs to restart the OAuth flow from the authorise step.
101
+
102
+ ### `XeroKiwi::ClientError` (other HTTP 4xx)
103
+
104
+ A 4xx response that isn't 401 or 429. Most commonly:
105
+
106
+ | Status | Meaning |
107
+ |--------|---------|
108
+ | 400 | Bad request — usually a malformed body or query param |
109
+ | 403 | The token is valid but doesn't have the right scope/permission |
110
+ | 404 | The resource doesn't exist (or was already deleted) |
111
+ | 422 | Validation error — Xero rejected the payload |
112
+
113
+ The body will usually contain a Xero-specific error structure with details.
114
+ Inspect `error.body` to surface it.
115
+
116
+ ### `XeroKiwi::ServerError` (HTTP 5xx)
117
+
118
+ A 5xx response that wasn't retried. XeroKiwi retries 502/503/504 automatically
119
+ (see [retries and rate limits](retries-and-rate-limits.md)) so by the time
120
+ you see this, the retries are exhausted or the status was 500 (which XeroKiwi
121
+ deliberately doesn't retry, since 500s are usually persistent bugs in the
122
+ request rather than transient infrastructure issues).
123
+
124
+ ### `XeroKiwi::RateLimitError` (HTTP 429)
125
+
126
+ A subclass of `APIError`. Raised when retries on a 429 are exhausted.
127
+
128
+ Extra attributes beyond `APIError`:
129
+
130
+ | Attribute | Type | What it is |
131
+ |-----------|------|------------|
132
+ | `error.retry_after` | `Float` or `nil` | The value of the `Retry-After` header in seconds, if Xero sent one. |
133
+ | `error.problem` | `String` or `nil` | The value of the `X-Rate-Limit-Problem` header — typically `"minute"`, `"day"`, or `"appminute"`. Tells you which limit you hit. |
134
+
135
+ `retry_after` lets your application decide how to back off — for example, a
136
+ Sidekiq job can re-enqueue itself with that delay rather than hammering
137
+ Xero.
138
+
139
+ ### `XeroKiwi::OAuth::StateMismatchError`
140
+
141
+ Raised by `XeroKiwi::OAuth.verify_state!` when the `state` parameter Xero
142
+ echoed back doesn't match what you stashed before redirecting. **This
143
+ indicates a forged callback or a session that was lost between request and
144
+ response.** Treat it as a security event — log it and refuse to proceed.
145
+
146
+ This is *not* a Xero error; it's a local CSRF check that fires before any
147
+ HTTP call.
148
+
149
+ ### `XeroKiwi::OAuth::IDTokenError`
150
+
151
+ Raised by `XeroKiwi::OAuth::IDToken.verify` (or `oauth.verify_id_token`) when
152
+ the ID token JWT can't be verified. Causes:
153
+
154
+ - Bad signature.
155
+ - Wrong issuer.
156
+ - Wrong audience.
157
+ - Token expired.
158
+ - Nonce mismatch (when nonce verification was requested).
159
+ - Network failure fetching JWKS.
160
+
161
+ The error message has a brief description of which check failed (e.g.
162
+ `"ID token verification failed: Signature verification failed"`,
163
+ `"ID token nonce mismatch"`).
164
+
165
+ ## What to catch when
166
+
167
+ ### "Any XeroKiwi failure"
168
+
169
+ ```ruby
170
+ begin
171
+ client.connections
172
+ rescue XeroKiwi::Error => e
173
+ Rails.logger.error("Xero call failed: #{e.message}")
174
+ end
175
+ ```
176
+
177
+ ### "Authentication broke; user needs to re-auth"
178
+
179
+ ```ruby
180
+ begin
181
+ client.connections
182
+ rescue XeroKiwi::AuthenticationError
183
+ redirect_to xero_reauth_path
184
+ end
185
+ ```
186
+
187
+ This catches `TokenRefreshError` and `CodeExchangeError` too, since they're
188
+ both `AuthenticationError` subclasses. That's usually what you want — they
189
+ all imply "the credentials are dead, restart the flow."
190
+
191
+ ### "Rate limited — back off"
192
+
193
+ ```ruby
194
+ begin
195
+ client.connections
196
+ rescue XeroKiwi::RateLimitError => e
197
+ RetryWorker.perform_in(e.retry_after.seconds, args)
198
+ end
199
+ ```
200
+
201
+ ### "Anything Xero said no to"
202
+
203
+ ```ruby
204
+ begin
205
+ client.connections
206
+ rescue XeroKiwi::APIError => e
207
+ Rails.logger.warn("Xero #{e.status}: #{e.body}")
208
+ raise
209
+ end
210
+ ```
211
+
212
+ ### "OAuth callback failed for any reason"
213
+
214
+ ```ruby
215
+ def callback
216
+ XeroKiwi::OAuth.verify_state!(received: params[:state], expected: session.delete(:xero_state))
217
+ token = oauth.exchange_code(code: params[:code], code_verifier: session.delete(:xero_verifier))
218
+ oauth.verify_id_token(token.id_token)
219
+ # ...
220
+ rescue XeroKiwi::OAuth::StateMismatchError
221
+ redirect_to root_path, alert: "Authentication failed (CSRF check)"
222
+ rescue XeroKiwi::OAuth::CodeExchangeError
223
+ redirect_to root_path, alert: "Could not complete Xero authorisation"
224
+ rescue XeroKiwi::OAuth::IDTokenError
225
+ redirect_to root_path, alert: "Could not verify Xero identity"
226
+ end
227
+ ```
228
+
229
+ Three separate rescue clauses give you three different user-facing
230
+ messages, which is usually what you want for an OAuth callback — different
231
+ failures imply different user actions.
232
+
233
+ ### Distinguishing TokenRefreshError from generic 401
234
+
235
+ If you want to handle the rotation race specifically:
236
+
237
+ ```ruby
238
+ begin
239
+ client.connections
240
+ rescue XeroKiwi::TokenRefreshError
241
+ credential.reload
242
+ if credential.refresh_token != original_refresh_token
243
+ retry # another process refreshed; pick up their token
244
+ else
245
+ credential.update!(needs_reauth: true)
246
+ raise
247
+ end
248
+ rescue XeroKiwi::AuthenticationError
249
+ # 401 that wasn't a refresh failure (e.g. token revoked at Xero's end)
250
+ credential.update!(needs_reauth: true)
251
+ end
252
+ ```
253
+
254
+ Order matters — `TokenRefreshError` is more specific than
255
+ `AuthenticationError`, so it must come first.
256
+
257
+ ## Things the error system deliberately does NOT do
258
+
259
+ - **No "this error is retryable" predicate.** XeroKiwi already retries the
260
+ cases that *should* be retried at the HTTP level. By the time an exception
261
+ reaches your code, the retries are exhausted and the situation is
262
+ application-level. Adding `error.retryable?` would create a tempting
263
+ foot-gun where callers retry inside their own code, doubling up on the
264
+ retries XeroKiwi is already doing.
265
+ - **No automatic Sentry / Bugsnag integration.** Errors raise normally;
266
+ configure your own observability layer to catch them at the boundary.
267
+ - **No `error.code` enum.** The HTTP `status` is the enum. Inspecting it
268
+ in user code is usually fine (`error.status == 404`).
269
+ - **No localised error messages.** Messages are English and developer-facing.
270
+ Translate them to user-facing language at your application's surface,
271
+ not in the gem.
@@ -0,0 +1,138 @@
1
+ # Getting started
2
+
3
+ This guide walks you from a fresh checkout to your first successful Xero API
4
+ call. If you already know what OAuth2 is and just want the API reference, jump
5
+ straight to [Client](client.md) or [OAuth](oauth.md).
6
+
7
+ ## Prerequisites
8
+
9
+ Before you can talk to Xero from Ruby, you need:
10
+
11
+ 1. **A Xero developer account.** Sign up at
12
+ <https://developer.xero.com>.
13
+ 2. **A Xero app**, registered in the developer portal. The app gives you a
14
+ `client_id` and `client_secret` and lets you configure a redirect URL.
15
+ 3. **Ruby 3.4.1 or newer.**
16
+
17
+ ## Installing the gem
18
+
19
+ Add XeroKiwi to your `Gemfile`:
20
+
21
+ ```ruby
22
+ gem "xero-kiwi"
23
+ ```
24
+
25
+ Then:
26
+
27
+ ```sh
28
+ bundle install
29
+ ```
30
+
31
+ ## The mental model
32
+
33
+ XeroKiwi is built around a small set of objects, each with one job:
34
+
35
+ | Object | What it does |
36
+ |--------|--------------|
37
+ | [`XeroKiwi::OAuth`](oauth.md) | Drives the OAuth2 authorization-code flow. Builds authorise URLs, exchanges codes for tokens, verifies ID tokens, revokes tokens. |
38
+ | [`XeroKiwi::Token`](tokens.md) | An immutable value object holding an access/refresh pair plus expiry metadata. Knows when it's expired or expiring. |
39
+ | [`XeroKiwi::Client`](client.md) | The API gateway. You give it a token (or full credentials) and call methods like `client.connections`. Handles retries, refresh, error mapping. |
40
+ | [`XeroKiwi::Connection`](connections.md) | A Xero "connection" — one tenant (organisation or practice) that an access token is authorised against. |
41
+ | [`XeroKiwi::Accounting::Organisation`](accounting/organisation.md) | A Xero organisation — the accounting entity (company, trust, etc.) behind a tenant. |
42
+
43
+ The flow is always: **OAuth → Token → Client → resources.** OAuth gets you a
44
+ Token; you hand the Token to a Client; the Client lets you call resource
45
+ methods.
46
+
47
+ ## Your first request — the short version
48
+
49
+ If you already have an access token (e.g. from a previous OAuth dance, or from
50
+ the Xero developer portal's "Try it" tooling), making a request is one line:
51
+
52
+ ```ruby
53
+ require "xero_kiwi"
54
+
55
+ client = XeroKiwi::Client.new(access_token: "ya29...")
56
+ client.connections # => [#<XeroKiwi::Connection ...>, ...]
57
+ ```
58
+
59
+ This is enough to get started, but the access token will expire after 30
60
+ minutes and you have no way to refresh it. For anything beyond a quick script,
61
+ read on.
62
+
63
+ ## Your first request — the full version
64
+
65
+ A production-grade integration looks more like this:
66
+
67
+ ```ruby
68
+ require "xero_kiwi"
69
+
70
+ # 1. Set up the OAuth helper. You only need redirect_uri for the auth-code
71
+ # flow itself; for refresh-only / revoke-only callers it's optional.
72
+ oauth = XeroKiwi::OAuth.new(
73
+ client_id: ENV.fetch("XERO_CLIENT_ID"),
74
+ client_secret: ENV.fetch("XERO_CLIENT_SECRET"),
75
+ redirect_uri: "https://app.example.com/xero/callback"
76
+ )
77
+
78
+ # 2. Send the user to Xero. Stash the state + PKCE verifier in your session
79
+ # so you can verify them when the user comes back.
80
+ state = XeroKiwi::OAuth.generate_state
81
+ pkce = XeroKiwi::OAuth.generate_pkce
82
+ session[:xero_state] = state
83
+ session[:xero_verifier] = pkce.verifier
84
+
85
+ redirect_to oauth.authorization_url(
86
+ scopes: %w[openid profile email accounting.transactions offline_access],
87
+ state: state,
88
+ pkce: pkce
89
+ )
90
+
91
+ # 3. In your callback handler, verify state, exchange the code, persist the
92
+ # tokens, and you're ready to call the API.
93
+ XeroKiwi::OAuth.verify_state!(
94
+ received: params[:state],
95
+ expected: session.delete(:xero_state)
96
+ )
97
+
98
+ token = oauth.exchange_code(
99
+ code: params[:code],
100
+ code_verifier: session.delete(:xero_verifier)
101
+ )
102
+
103
+ XeroCredential.create!(
104
+ access_token: token.access_token,
105
+ refresh_token: token.refresh_token,
106
+ expires_at: token.expires_at
107
+ )
108
+
109
+ # 4. Now build a refresh-capable client and use it. The client will refresh
110
+ # automatically when the token expires; the on_token_refresh callback
111
+ # persists the rotated token back to your storage.
112
+ credential = XeroCredential.last
113
+
114
+ client = XeroKiwi::Client.new(
115
+ access_token: credential.access_token,
116
+ refresh_token: credential.refresh_token,
117
+ expires_at: credential.expires_at,
118
+ client_id: ENV.fetch("XERO_CLIENT_ID"),
119
+ client_secret: ENV.fetch("XERO_CLIENT_SECRET"),
120
+ on_token_refresh: ->(token) { credential.update!(token.to_h) }
121
+ )
122
+
123
+ client.connections.each do |connection|
124
+ puts "#{connection.tenant_name} (#{connection.tenant_id})"
125
+ end
126
+ ```
127
+
128
+ That's the whole loop: authorise, exchange, persist, call the API.
129
+
130
+ ## Where to go next
131
+
132
+ - [OAuth](oauth.md) — the full reference for the auth-code flow, PKCE, ID token
133
+ verification, and revocation.
134
+ - [Client](client.md) — every constructor option for `XeroKiwi::Client`, the request
135
+ lifecycle, custom adapters and retry policy.
136
+ - [Tokens](tokens.md) — how refresh works, the persistence callback, manual vs
137
+ automatic refresh, multi-process gotchas.
138
+ - [Errors](errors.md) — the full error hierarchy and what to rescue when.