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,224 @@
1
+ # Retries and rate limits
2
+
3
+ This doc explains how XeroKiwi handles transient failures: rate limiting, server
4
+ errors, and network blips. Most of the time you don't need to know any of
5
+ this — the defaults are sensible. Read on if you need to tune the retry
6
+ policy or you want to understand what's happening when a request mysteriously
7
+ takes 4 seconds.
8
+
9
+ ## Xero's rate limits
10
+
11
+ Xero enforces three separate rate limits, all returned with HTTP 429:
12
+
13
+ | Limit | Default | Header indicator |
14
+ |-------|---------|------------------|
15
+ | Per-minute (per tenant) | 60 calls | `X-Rate-Limit-Problem: minute` |
16
+ | Daily (per tenant) | 5,000 calls | `X-Rate-Limit-Problem: day` |
17
+ | Per-app per-minute | 10,000 calls | `X-Rate-Limit-Problem: appminute` |
18
+ | Concurrent | 10 simultaneous requests | (no specific problem header) |
19
+
20
+ Plus a `Retry-After` header on every 429 telling you how many seconds to
21
+ wait before trying again.
22
+
23
+ ## What XeroKiwi does automatically
24
+
25
+ XeroKiwi sets up a `faraday-retry` middleware that handles transient failures
26
+ without any code from you. The default retry policy:
27
+
28
+ | Setting | Default | Why |
29
+ |---------|---------|-----|
30
+ | `max` | 4 retries | High enough to ride out most rate-limit pauses, low enough to fail fast on real outages. |
31
+ | `interval` | 0.5 s | Initial wait. |
32
+ | `backoff_factor` | 2 | Exponential backoff. So waits are roughly 0.5s, 1s, 2s, 4s. |
33
+ | `interval_randomness` | 0.5 | ±50% jitter on each wait, so a herd of clients doesn't refresh in lockstep. |
34
+ | `retry_statuses` | `[429, 502, 503, 504]` | Statuses that get retried. |
35
+ | `methods` | All HTTP methods | Including POST/PUT/DELETE. (Xero's idempotency makes this safe.) |
36
+ | `exceptions` | `Faraday::ConnectionFailed`, `Faraday::TimeoutError`, `Faraday::RetriableResponse`, `Errno::ETIMEDOUT` | Transport-level failures that get retried. |
37
+
38
+ ### Retry-After is honoured
39
+
40
+ `faraday-retry` automatically respects the `Retry-After` header on 429
41
+ responses. So if Xero says "wait 30 seconds," XeroKiwi waits 30 seconds before
42
+ retrying — not the exponential backoff schedule. This is the whole reason
43
+ to use a real retry middleware instead of rolling your own.
44
+
45
+ ### Which 5xx are retried
46
+
47
+ XeroKiwi retries `502 Bad Gateway`, `503 Service Unavailable`, and `504 Gateway
48
+ Timeout`. These are the canonical "the upstream is having a temporary
49
+ problem" statuses.
50
+
51
+ **500 Internal Server Error is deliberately NOT retried.** A 500 usually
52
+ means Xero hit a real bug in handling your request — retrying the same
53
+ request will give the same 500. If you want to retry 500s in your own
54
+ code, catch `XeroKiwi::ServerError` and handle it explicitly.
55
+
56
+ ### What happens after retries are exhausted
57
+
58
+ | Scenario | Final exception |
59
+ |----------|-----------------|
60
+ | All retries returned 429 | `XeroKiwi::RateLimitError` (with `retry_after` and `problem` attributes) |
61
+ | All retries returned 502/503/504 | `XeroKiwi::ServerError` |
62
+ | All retries failed at the transport level | The underlying `Faraday::ConnectionFailed` / `Faraday::TimeoutError` |
63
+
64
+ The retried request count includes the original attempt, so `max: 4` means
65
+ **up to 5 total HTTP requests** before giving up.
66
+
67
+ ## Customising the retry policy
68
+
69
+ Pass `retry_options:` to `XeroKiwi::Client.new`. The hash is merged into XeroKiwi's
70
+ defaults, so you only specify what you want to change:
71
+
72
+ ```ruby
73
+ client = XeroKiwi::Client.new(
74
+ access_token: "...",
75
+ retry_options: {
76
+ max: 8, # try up to 8 retries
77
+ interval: 1.0 # initial 1s wait instead of 0.5s
78
+ }
79
+ )
80
+ ```
81
+
82
+ ### Common customisations
83
+
84
+ **Aggressive retries for batch jobs that can wait:**
85
+
86
+ ```ruby
87
+ retry_options: {
88
+ max: 10,
89
+ interval: 2.0,
90
+ backoff_factor: 2
91
+ }
92
+ # Waits up to ~17 minutes total (2, 4, 8, 16, 32, 64, 128, 256, 512, 1024 seconds)
93
+ ```
94
+
95
+ **No retries (e.g. for tests):**
96
+
97
+ ```ruby
98
+ retry_options: { max: 0 }
99
+ ```
100
+
101
+ **Tight retries with no jitter (also for tests):**
102
+
103
+ ```ruby
104
+ retry_options: {
105
+ max: 2,
106
+ interval: 0,
107
+ interval_randomness: 0,
108
+ backoff_factor: 1
109
+ }
110
+ ```
111
+
112
+ This is what XeroKiwi's own test suite uses to keep specs deterministic and
113
+ fast.
114
+
115
+ **Adding 500 to the retry list** (against my advice, but sometimes you
116
+ have a known-flaky upstream):
117
+
118
+ ```ruby
119
+ retry_options: {
120
+ retry_statuses: [429, 500, 502, 503, 504]
121
+ }
122
+ ```
123
+
124
+ ### One thing you must NOT remove
125
+
126
+ XeroKiwi's default `exceptions:` list includes `Faraday::RetriableResponse`,
127
+ which is the *internal* signal `faraday-retry` uses to flag a status-code
128
+ retry. **It must stay in the list**, or the retry middleware can't catch
129
+ its own retry signal and 429s/503s will never be retried — they'll bubble
130
+ straight up as raw `Faraday::RetriableResponse` exceptions.
131
+
132
+ If you override `exceptions:`, make sure to include the four defaults:
133
+
134
+ ```ruby
135
+ retry_options: {
136
+ exceptions: [
137
+ Faraday::ConnectionFailed,
138
+ Faraday::TimeoutError,
139
+ Faraday::RetriableResponse, # ← critical
140
+ Errno::ETIMEDOUT,
141
+ MyOwnException # add your own
142
+ ]
143
+ }
144
+ ```
145
+
146
+ In practice, you almost never need to override this list — the defaults
147
+ cover everything Xero will throw at you.
148
+
149
+ ## How the middleware stack is wired
150
+
151
+ The order of Faraday middleware matters and was the source of one nasty
152
+ bug during development. The chain looks like this:
153
+
154
+ ```
155
+ ResponseHandler ← outermost, catches errors AFTER retries are exhausted
156
+
157
+ Retry ← retries 429/503/etc on the way back, respects Retry-After
158
+
159
+ JSON ← parses response bodies
160
+
161
+ Adapter ← actually makes the HTTP call
162
+ ```
163
+
164
+ The trick is putting **`ResponseHandler` outside `Retry`**. If they were the
165
+ other way round, a 429 would go: adapter returns env → JSON parses →
166
+ ResponseHandler raises `RateLimitError` → Retry catches the exception →
167
+ needs to know about `RateLimitError` to know to retry it. That's brittle.
168
+
169
+ By putting Retry on the inside, the retry middleware sees raw HTTP envs
170
+ with status 429 and uses its own `retry_statuses` config to decide what to
171
+ do. ResponseHandler only sees the *final* env (after retries are done) and
172
+ maps it to a XeroKiwi exception.
173
+
174
+ You don't need to think about any of this — it's the gem's job — but if
175
+ you ever subclass the client or insert your own middleware, this is the
176
+ ordering to preserve.
177
+
178
+ ## Token refresh on 401
179
+
180
+ Token refresh isn't part of the retry middleware — it's handled separately
181
+ by `XeroKiwi::Client#with_authenticated_request` (see [Client — request
182
+ lifecycle](client.md#the-request-lifecycle)). The two systems compose
183
+ cleanly:
184
+
185
+ 1. Client wraps the call in `with_authenticated_request`.
186
+ 2. The retry middleware retries 429/503 inside the wrapper.
187
+ 3. If retries are exhausted with a 401, ResponseHandler raises
188
+ `AuthenticationError`.
189
+ 4. `with_authenticated_request` catches it, refreshes the token, and
190
+ retries the *outer* call exactly once.
191
+
192
+ Crucially, step 4 retries the **whole call**, including the retry
193
+ middleware. So a single API call can trigger up to `max + 1` HTTP attempts
194
+ *twice*: once before the refresh, once after.
195
+
196
+ ## Concurrency notes
197
+
198
+ The retry middleware is per-request, not per-client, so multiple concurrent
199
+ requests on the same `XeroKiwi::Client` each get their own retry budget. Two
200
+ threads racing on a rate-limited tenant will each see independent
201
+ retry/backoff schedules — they won't coordinate.
202
+
203
+ If you need cross-thread coordination (e.g. "all threads should pause when
204
+ any one of them hits a 429"), build it at the application level using a
205
+ shared semaphore or rate limiter. XeroKiwi doesn't ship one, because the right
206
+ shape depends entirely on your traffic patterns.
207
+
208
+ ## Things the retry layer deliberately does NOT do
209
+
210
+ - **No retry on 500.** 500s are usually persistent. If you want them
211
+ retried, add to `retry_statuses` explicitly.
212
+ - **No retry on 4xx (other than 429).** 4xx means the client did something
213
+ wrong; retrying won't fix it. The exception is 429 (rate limit), which is
214
+ classified as 4xx but is fundamentally a "wait and try again" signal.
215
+ - **No global rate limiter.** XeroKiwi reacts to 429s as they happen but
216
+ doesn't proactively throttle — it'll happily fire 100 concurrent requests
217
+ and rely on Xero's 429s to slow things down. If you need proactive
218
+ throttling, do it at your job-queue level.
219
+ - **No retry budget across calls.** Each `client.connections` (or any
220
+ other call) gets a fresh `max` retries. There's no concept of "this client
221
+ has had too many retries today and should stop trying."
222
+ - **No automatic Sidekiq integration.** When `RateLimitError` raises, it's
223
+ up to your job to re-enqueue using the `retry_after` value. XeroKiwi exposes
224
+ it; what you do with it is your call.
data/docs/tokens.md ADDED
@@ -0,0 +1,339 @@
1
+ # Tokens
2
+
3
+ This doc covers everything about token *state* in XeroKiwi: the `XeroKiwi::Token`
4
+ value object, how the client refreshes tokens automatically, the persistence
5
+ callback, manual refresh, revocation, and the gotchas around token rotation.
6
+
7
+ For the OAuth *protocol* (building authorise URLs, exchanging codes), see
8
+ [OAuth](oauth.md).
9
+
10
+ ## The `XeroKiwi::Token` value object
11
+
12
+ A `XeroKiwi::Token` is an immutable bundle of OAuth state — the access/refresh
13
+ pair plus all the metadata Xero returns at refresh time:
14
+
15
+ ```ruby
16
+ token = XeroKiwi::Token.new(
17
+ access_token: "ya29...",
18
+ refresh_token: "1//...",
19
+ expires_at: Time.now + 1800,
20
+ token_type: "Bearer",
21
+ id_token: "eyJhbG...",
22
+ scope: "openid offline_access accounting.transactions"
23
+ )
24
+ ```
25
+
26
+ You usually don't construct one by hand — it's what `OAuth#exchange_code` and
27
+ `OAuth#refresh_token` (via `TokenRefresher`) return, and what
28
+ `XeroKiwi::Client#token` exposes.
29
+
30
+ ### Constructor options
31
+
32
+ | Option | Type | Default | Required |
33
+ |--------|------|---------|----------|
34
+ | `access_token:` | `String` | — | Yes |
35
+ | `refresh_token:` | `String` | `nil` | No |
36
+ | `expires_at:` | `Time` | `nil` | No |
37
+ | `token_type:` | `String` | `"Bearer"` | No |
38
+ | `id_token:` | `String` | `nil` | No |
39
+ | `scope:` | `String` | `nil` | No |
40
+
41
+ ### Building from a Xero OAuth response
42
+
43
+ `Token.from_oauth_response` converts a Xero token-endpoint payload (which
44
+ returns `expires_in` as seconds-from-now) into a Token with an absolute
45
+ `expires_at`:
46
+
47
+ ```ruby
48
+ payload = {
49
+ "access_token" => "...",
50
+ "refresh_token" => "...",
51
+ "expires_in" => 1800,
52
+ "token_type" => "Bearer",
53
+ "scope" => "openid offline_access ...",
54
+ "id_token" => "..."
55
+ }
56
+
57
+ token = XeroKiwi::Token.from_oauth_response(payload)
58
+ # expires_at is computed as Time.now + 1800
59
+
60
+ # You can also pin the anchor time, which is useful for tests:
61
+ token = XeroKiwi::Token.from_oauth_response(payload, requested_at: some_time)
62
+ ```
63
+
64
+ This method accepts both string-keyed and symbol-keyed payloads.
65
+
66
+ ## Expiry helpers
67
+
68
+ | Method | What it returns |
69
+ |--------|-----------------|
70
+ | `token.expired?(now: Time.now)` | `true` if `expires_at` is in the past. **Returns `false` when `expires_at` is nil** — without an expiry we have no signal to act on. |
71
+ | `token.expiring_soon?(within: 60, now: Time.now)` | `true` if `expires_at` falls within `within` seconds of `now`. The default 60s window is what `XeroKiwi::Client` uses for its proactive refresh. |
72
+ | `token.valid?(now: Time.now)` | `true` if the access token is non-empty AND not expired. |
73
+ | `token.refreshable?` | `true` if a non-empty refresh token is present. |
74
+
75
+ The `now:` keyword arg lets you inject a fixed time for testing.
76
+
77
+ ### Why `expired?` returns false on nil `expires_at`
78
+
79
+ If you don't know when the token expires (e.g. you loaded a credential from
80
+ storage that was created before you tracked expiry), XeroKiwi treats it as
81
+ "unknown" and assumes valid. The fallback is reactive — your first 401 will
82
+ trigger a refresh.
83
+
84
+ Set `expires_at:` whenever you can; it's strictly better than relying on
85
+ reactive refresh, which costs you a wasted API call before the refresh fires.
86
+
87
+ ## Refreshing the token
88
+
89
+ There are two ways the client refreshes tokens for you:
90
+
91
+ ### Automatic (preferred)
92
+
93
+ If you constructed the client with refresh credentials, every API call goes
94
+ through these checks:
95
+
96
+ 1. **Proactive** — before the request fires, if `token.expiring_soon?`
97
+ returns true, the client refreshes first.
98
+ 2. **Reactive** — if the request returns 401 anyway, the client refreshes
99
+ and retries the request once.
100
+
101
+ You don't have to do anything to opt in — it's the default behaviour. Just
102
+ make sure to provide all the refresh ingredients at construction time:
103
+
104
+ ```ruby
105
+ client = XeroKiwi::Client.new(
106
+ access_token: credential.access_token,
107
+ refresh_token: credential.refresh_token,
108
+ expires_at: credential.expires_at,
109
+ client_id: ENV.fetch("XERO_CLIENT_ID"),
110
+ client_secret: ENV.fetch("XERO_CLIENT_SECRET"),
111
+ on_token_refresh: ->(token) { credential.update!(token.to_h) }
112
+ )
113
+ ```
114
+
115
+ ### Manual
116
+
117
+ If you need to force a refresh outside of an API call:
118
+
119
+ ```ruby
120
+ new_token = client.refresh_token!
121
+ # or just:
122
+ client.refresh_token!
123
+ client.token # the new token
124
+ ```
125
+
126
+ `refresh_token!` raises `XeroKiwi::TokenRefreshError` if:
127
+
128
+ - The client has no refresh credentials (`client_id`/`client_secret` missing).
129
+ - The current token has no `refresh_token`.
130
+ - Xero rejects the refresh (e.g. `invalid_grant` because the refresh token
131
+ was already rotated by another process — see
132
+ [the rotation gotcha](#refresh-token-rotation) below).
133
+
134
+ ### `client.can_refresh?`
135
+
136
+ True if and only if all the ingredients are in place: client credentials *and*
137
+ a non-empty refresh token. Use this to check before calling `refresh_token!`
138
+ explicitly:
139
+
140
+ ```ruby
141
+ if client.can_refresh?
142
+ client.refresh_token!
143
+ else
144
+ # surface a "needs re-auth" state
145
+ end
146
+ ```
147
+
148
+ ## The `on_token_refresh` callback
149
+
150
+ This is the most important integration point in the gem. Every time the
151
+ client refreshes the token — proactively, reactively, or via a manual
152
+ `refresh_token!` — it calls your callback with the new `XeroKiwi::Token`:
153
+
154
+ ```ruby
155
+ on_token_refresh: ->(token) { credential.update!(token.to_h) }
156
+ ```
157
+
158
+ The callback fires from inside the refresh mutex, so by the time it runs,
159
+ the new token is the canonical in-memory state. If you don't persist it,
160
+ the next process to load the credential will use the now-invalid old refresh
161
+ token and fail.
162
+
163
+ ### Persistence patterns
164
+
165
+ Different applications store credentials differently. The callback works the
166
+ same in all of them — XeroKiwi just hands you the new token and trusts you to
167
+ put it somewhere:
168
+
169
+ ```ruby
170
+ # Rails / ActiveRecord
171
+ on_token_refresh: ->(token) { credential.update!(access_token: token.access_token, refresh_token: token.refresh_token, expires_at: token.expires_at) }
172
+
173
+ # Sequel
174
+ on_token_refresh: ->(token) { DB[:credentials].where(id: id).update(token.to_h) }
175
+
176
+ # Local CLI tool with a JSON file
177
+ on_token_refresh: ->(token) { File.write("~/.xero", JSON.dump(token.to_h)) }
178
+
179
+ # Background sync architecture: write through a queue so persistence
180
+ # happens in a single-writer worker (avoids the rotation race below)
181
+ on_token_refresh: ->(token) { TokenWriter.enqueue(tenant_id, token.to_h) }
182
+ ```
183
+
184
+ The callback receives a `XeroKiwi::Token`, so you can call `.to_h` to get a
185
+ plain hash, or pick fields off it directly.
186
+
187
+ ## Refresh token rotation
188
+
189
+ **Xero rotates refresh tokens on every use.** When you successfully refresh,
190
+ Xero returns a *new* refresh token alongside the new access token, and the
191
+ old refresh token is immediately invalidated. There is no way to recover it.
192
+
193
+ This is the source of the most common production bug with OAuth-based
194
+ integrations:
195
+
196
+ > **Two workers race.** Worker A and Worker B both load the same credential
197
+ > row from the database, both notice the token is expiring, and both try to
198
+ > refresh. Worker A wins, gets a new refresh token, persists it. Worker B
199
+ > tries to use the old refresh token (which it loaded before A persisted)
200
+ > and gets `invalid_grant`. From Worker B's perspective, the credential
201
+ > looks dead — but it's not, A just rotated it.
202
+
203
+ XeroKiwi mitigates this in two ways:
204
+
205
+ 1. **Single-process safety.** A `Mutex` around refresh, with a double-check
206
+ inside, prevents multiple threads in the *same process* from racing.
207
+ 2. **Single-process safety only.** The mutex doesn't help across processes.
208
+
209
+ ### Multi-process refresh
210
+
211
+ If you have multiple processes sharing one credential (e.g. Sidekiq workers,
212
+ multiple Rails servers behind a load balancer), the in-process mutex doesn't
213
+ protect you. Options:
214
+
215
+ #### Option A — Single-writer architecture
216
+
217
+ Funnel all refreshes through one process. When a worker notices a token is
218
+ expiring, it enqueues a "please refresh this credential" job to a single
219
+ worker that handles refresh exclusively. The worker that requested the
220
+ refresh waits for the result (or just retries with the freshly-loaded
221
+ credential).
222
+
223
+ This is the cleanest approach but requires architecture work.
224
+
225
+ #### Option B — Catch the race and reload
226
+
227
+ When `XeroKiwi::TokenRefreshError` fires, the most common cause is "another
228
+ process already refreshed." Reload the credential from the database — if
229
+ the refresh token has changed, use the new one and retry. If it's the
230
+ same, the credential is genuinely dead and you need to re-authorise.
231
+
232
+ ```ruby
233
+ def with_xero_client
234
+ credential = XeroCredential.find(id)
235
+ client = build_client(credential)
236
+
237
+ yield client
238
+
239
+ rescue XeroKiwi::TokenRefreshError
240
+ credential.reload
241
+ if credential.refresh_token != client.token.refresh_token
242
+ retry # another process refreshed; pick up their new token
243
+ else
244
+ credential.update!(needs_reauth: true)
245
+ raise
246
+ end
247
+ end
248
+ ```
249
+
250
+ #### Option C — Distributed lock
251
+
252
+ Use Redis (or Postgres advisory locks, etc.) to take a distributed lock
253
+ keyed by credential ID before refreshing. Slower but more robust than
254
+ Option B.
255
+
256
+ XeroKiwi doesn't ship any of these — they're application-level decisions that
257
+ depend on your infrastructure. But the existence of `client.token.refreshable?`
258
+ and the explicit `XeroKiwi::TokenRefreshError` give you the building blocks.
259
+
260
+ ## Revoking tokens
261
+
262
+ Revocation tells Xero "please invalidate this token." Use it for "disconnect
263
+ Xero from my app" / logout flows.
264
+
265
+ ### Via `XeroKiwi::Client`
266
+
267
+ ```ruby
268
+ client.revoke_token!
269
+ credential.destroy! # caller's job
270
+ ```
271
+
272
+ This:
273
+
274
+ 1. POSTs to `https://identity.xero.com/connect/revocation` with the client's
275
+ current refresh token.
276
+ 2. Returns `true` on success.
277
+ 3. Raises `XeroKiwi::TokenRefreshError` if the client has no refresh capability,
278
+ or `XeroKiwi::AuthenticationError` if Xero rejects the revoke.
279
+
280
+ After revocation, **treat the client as dead.** Subsequent API calls will
281
+ 401, and reactive refresh will fail because the refresh token is gone too.
282
+ XeroKiwi doesn't set an internal flag on the client — there's no
283
+ `client.revoked?` predicate — because the right thing for a caller to do
284
+ post-revocation is throw the client away, not keep using it.
285
+
286
+ ### Via `XeroKiwi::OAuth` directly
287
+
288
+ If you have a refresh token in hand and don't want to construct a Client:
289
+
290
+ ```ruby
291
+ oauth = XeroKiwi::OAuth.new(
292
+ client_id: ENV.fetch("XERO_CLIENT_ID"),
293
+ client_secret: ENV.fetch("XERO_CLIENT_SECRET")
294
+ # no redirect_uri needed for revoke-only
295
+ )
296
+
297
+ oauth.revoke_token(refresh_token: stored_refresh_token)
298
+ ```
299
+
300
+ `OAuth#revoke_token` is the protocol-level method; `Client#revoke_token!`
301
+ is a thin convenience over it.
302
+
303
+ ### Why we always pass the refresh token, not the access token
304
+
305
+ Per RFC 7009 you can revoke either the access token or the refresh token.
306
+ Xero accepts both. **But** revoking the access token only kills that one
307
+ access token — the refresh token stays alive and can mint a new one
308
+ immediately. Revoking the refresh token invalidates the entire chain.
309
+
310
+ XeroKiwi enforces the refresh-token path: `Client#revoke_token!` raises if you
311
+ don't have a refresh token, rather than silently revoking the access token
312
+ (which would do almost nothing useful). This avoids a foot-gun where users
313
+ think they've logged out but the token is still happily working.
314
+
315
+ ## Inspecting tokens
316
+
317
+ `XeroKiwi::Token#inspect` redacts the access token so it doesn't accidentally
318
+ end up in logs:
319
+
320
+ ```ruby
321
+ token.inspect
322
+ # => "#<XeroKiwi::Token access_token=[FILTERED] refreshable=true expires_at=2026-04-09 14:30:00 UTC>"
323
+ ```
324
+
325
+ `token.to_h` does NOT redact — it returns the full hash for storage. Don't
326
+ log the full hash.
327
+
328
+ ## Things tokens deliberately do NOT do
329
+
330
+ - **No automatic expiry from `Time.now`.** Tokens are immutable; their
331
+ expiry is fixed at construction. The "expiring" predicates are
332
+ computations against a `now:` parameter, not stateful checks.
333
+ - **No comparison of access token bytes.** `XeroKiwi::Token#==` compares the
334
+ full hash, which works for "is this the same token?" but isn't a security
335
+ check. Don't use it for authentication.
336
+ - **No JWT decoding of the access token.** Xero's access tokens are JWTs,
337
+ but XeroKiwi doesn't peek at their contents. The `id_token` is the OIDC
338
+ identity assertion you should care about; see [OAuth](oauth.md#id-token-verification)
339
+ for verifying it.
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module XeroKiwi
4
+ module Accounting
5
+ # A Xero address. Used by Organisation, Contact, and other resources.
6
+ #
7
+ # See: https://developer.xero.com/documentation/api/accounting/types#addresses
8
+ class Address
9
+ ATTRIBUTES = {
10
+ address_type: "AddressType",
11
+ address_line_1: "AddressLine1",
12
+ address_line_2: "AddressLine2",
13
+ address_line_3: "AddressLine3",
14
+ address_line_4: "AddressLine4",
15
+ city: "City",
16
+ region: "Region",
17
+ postal_code: "PostalCode",
18
+ country: "Country",
19
+ attention_to: "AttentionTo"
20
+ }.freeze
21
+
22
+ attr_reader(*ATTRIBUTES.keys)
23
+
24
+ def initialize(attrs)
25
+ attrs = attrs.transform_keys(&:to_s)
26
+ @address_type = attrs["AddressType"]
27
+ @address_line_1 = attrs["AddressLine1"]
28
+ @address_line_2 = attrs["AddressLine2"]
29
+ @address_line_3 = attrs["AddressLine3"]
30
+ @address_line_4 = attrs["AddressLine4"]
31
+ @city = attrs["City"]
32
+ @region = attrs["Region"]
33
+ @postal_code = attrs["PostalCode"]
34
+ @country = attrs["Country"]
35
+ @attention_to = attrs["AttentionTo"]
36
+ end
37
+
38
+ def street? = address_type == "STREET"
39
+ def pobox? = address_type == "POBOX"
40
+ def delivery? = address_type == "DELIVERY"
41
+
42
+ def to_h
43
+ ATTRIBUTES.keys.to_h { |key| [key, public_send(key)] }
44
+ end
45
+
46
+ def ==(other)
47
+ other.is_a?(Address) && to_h == other.to_h
48
+ end
49
+ alias eql? ==
50
+
51
+ def hash = to_h.hash
52
+
53
+ def inspect
54
+ "#<#{self.class} type=#{address_type.inspect} city=#{city.inspect} country=#{country.inspect}>"
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module XeroKiwi
6
+ module Accounting
7
+ # Represents an allocation of a credit note, prepayment, or overpayment
8
+ # against an invoice.
9
+ #
10
+ # See: https://developer.xero.com/documentation/api/accounting/overpayments
11
+ class Allocation
12
+ ATTRIBUTES = {
13
+ allocation_id: "AllocationID",
14
+ amount: "Amount",
15
+ date: "Date",
16
+ invoice: "Invoice",
17
+ is_deleted: "IsDeleted"
18
+ }.freeze
19
+
20
+ attr_reader(*ATTRIBUTES.keys)
21
+
22
+ def initialize(attrs)
23
+ attrs = attrs.transform_keys(&:to_s)
24
+ @allocation_id = attrs["AllocationID"]
25
+ @amount = attrs["Amount"]
26
+ @date = parse_time(attrs["Date"])
27
+ @invoice = attrs["Invoice"] ? Invoice.new(attrs["Invoice"], reference: true) : nil
28
+ @is_deleted = attrs["IsDeleted"]
29
+ end
30
+
31
+ def to_h
32
+ ATTRIBUTES.keys.to_h { |key| [key, public_send(key)] }
33
+ end
34
+
35
+ def ==(other)
36
+ other.is_a?(Allocation) && other.allocation_id == allocation_id
37
+ end
38
+ alias eql? ==
39
+
40
+ def hash = [self.class, allocation_id].hash
41
+
42
+ def inspect
43
+ "#<#{self.class} allocation_id=#{allocation_id.inspect} " \
44
+ "amount=#{amount.inspect}>"
45
+ end
46
+
47
+ private
48
+
49
+ def parse_time(value)
50
+ return nil if value.nil?
51
+
52
+ str = value.to_s.strip
53
+ return nil if str.empty?
54
+
55
+ if (match = str.match(%r{\A/Date\((\d+)([+-]\d{4})?\)/\z}))
56
+ Time.at(match[1].to_i / 1000.0).utc
57
+ else
58
+ str = "#{str}Z" unless str.match?(/[Zz]\z|[+-]\d{2}:?\d{2}\z/)
59
+ Time.iso8601(str)
60
+ end
61
+ rescue ArgumentError
62
+ nil
63
+ end
64
+ end
65
+ end
66
+ end