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,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
|