xero-kiwi 0.7.0 → 0.8.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e0e650e95ecce31d4acf75d1123d504bc11cacc9725e838643b6fbae235b04c0
4
- data.tar.gz: 950f370a803af1b1f6aacf87bcaadbf5d00f2d44b1cb54061bee0d74b70e5eae
3
+ metadata.gz: deaef4aaa66b0ac56631e391e34e5747c32da981244477ba212ff664cbe41cab
4
+ data.tar.gz: 6b3bd18bf430e1967d59875308e73749a13e3c2acc2279c9e58d065af94451db
5
5
  SHA512:
6
- metadata.gz: dbf4f32d568839238ce077d0243fe59ad0ba54f8e332b0870c6f23b53c63425f9a41b7842491d862e6bced6ad8bb14db71b1af5b83e704d1833e53b98a7a5ced
7
- data.tar.gz: 32e1d56e608bc844a452ca9a3a20ffff984c5a64ab1a46dc896498c3543c7deb9c581c5c1fc821ea40cff124b0d5e3ead035fc70e8a6857cce97754ef9131b90
6
+ metadata.gz: f34e40a3b9774b0891a06ded04d66dd5857ab9b036c5dadd301a1480894e1c00d71c414b0631c563309f4ab74318859d0c37cdfb4beee9ad9526a04b4edeb612
7
+ data.tar.gz: b10602ef4148ab48c07fffaecf1a7b65b4b828ef037384309e693326bd3e823bbda79a439cf59d2f69cdcdc623385787c84f326793dc2779c85d83f1f4a6a6d8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.0] - 2026-09-29
4
+
5
+ ### Breaking
6
+
7
+ - **Empty strings from Xero now read as `nil` on modelled attributes.** Xero sends `""` for a text field with no value, where its XML representation produced `nil`. Left as `""` it reaches a database column and quietly changes what queries match — a consumer's `where.not(xero_logo_url: nil)` started matching themes with no logo, and was one step from putting a blank image into customer statements.
8
+
9
+ Applies to `:string`, `:enum` and `:guid` attributes. `:date` has always behaved this way, so strings doing otherwise was an inconsistency rather than a principle. `:bool` and `:decimal` are untouched — there is no evidence Xero sends `""` for a numeric field, and that would want its own decision.
10
+
11
+ Only exactly `""` is affected. `" "` is left alone, because trimming it would be editorialising on a value rather than recognising an absent one. **`raw` is untouched** and still holds `""` verbatim, so the original payload is always recoverable.
12
+
13
+ Upgrading: code that chains off a string attribute without a guard (`contact.email_address.downcase`) will now raise `NoMethodError` where it silently operated on `""`. That is the intended trade — silent is the failure mode this release exists to remove.
14
+
15
+ Where to actually look: **optional text fields** — references, logo URLs, email addresses, invoice numbers — since those are the ones Xero leaves empty. Enums and IDs are effectively unaffected in practice because Xero always populates them, and code reading those tends to fail loudly either way (`hash.fetch("".downcase)` already raised `KeyError`; it now raises `NoMethodError` and loses the field name from the message). One consumer measured 14 unguarded `.downcase` calls against this change and needed no code changes at all.
16
+
17
+ ### Fixed
18
+
19
+ - `RateLimitCapture` now finds Xero's quota headers whatever case they arrive in. Faraday's own header container is case-insensitive so production was never affected, but a plain Hash is not, and Xero's headers are cased differently depending on who recorded them — a VCR cassette holds `X-Daylimit-Remaining`, not `X-DayLimit-Remaining`. Missing them records nothing, and `day_below?` answers false when nothing is known, so the failure would have been a quota check that passes while measuring nothing. Spotted by a consumer reading their own recorded cassettes against the lookup.
20
+
21
+ - **`Allocation#applied_amount` was nil for every allocation — 0.6.0 fixed the name and kept the bug.** Xero sends the allocated value under `"Amount"` in JSON and `"AppliedAmount"` in XML. 0.6.0 concluded the opposite, from payloads a legacy XML client had stored, and remapped the attribute to a key this JSON-only client never receives. Before 0.6.0 the mapping was right. Measured on a live tenant: 24 of 24 allocations carry `Amount`, none carry `AppliedAmount`, on both the list and single-resource endpoints.
22
+
23
+ Both keys are now modelled and **both `#amount` and `#applied_amount` resolve to whichever one arrived**, so neither can be nil while the other holds a value. This has been got wrong twice in opposite directions, each time producing a nil that an importer coerced to `0.0` and stored with nothing raising; reading both is cheaper than being certain. `to_h` reports the value under both keys, and `raw` still shows which key Xero sent.
24
+
25
+ ### Added
26
+
27
+ - `Accounting::User` now models **`global_user_id`** (`GlobalUserID`). Xero returns two identifiers that differ for every user: `user_id` is scoped to the organisation, `global_user_id` identifies the person across all of them and is what an OIDC `id_token` subject corresponds to. A consumer keyed membership records on `user_id` while its own user table used `GlobalUserID`, so every membership pointed at an identifier no user row carried — the association came back empty, silently, with the right count and the right roles.
28
+
29
+ - `Accounting::TrackingOption` now models the four booleans Xero returns alongside `Status`: `is_active`, `is_archived`, `is_deleted` and `has_validation_errors`. Confirmed against a live `GET /TrackingCategories` response — every key the endpoint returns on an option is now modelled. `#active?` still reads `status`, because the two agree on an active option and whether they diverge on an archived one is unconfirmed; `is_archived` and `is_deleted` are the ones to read when you need to tell those apart, since `status` collapses both into `"DELETED"`.
30
+
31
+ ### Documentation
32
+
33
+ - **Corrected what `Throttle::DailyLimitExhausted#retry_after` actually means.** Its docstring said the wait was "typically measured in hours" and described a reset boundary. The day bucket trickles like the minute bucket — `capacity / window_ms` per millisecond — so at `per_day: 4_900` a token accrues every 17.6 seconds, and that is what `retry_after` returns. There is no reset in the arithmetic. Reported by a consumer who read the comment, believed the wait would be hours, and designed an hourly sweep around it before measuring.
34
+
35
+ Re-enqueueing rather than blocking is still correct, but for a different reason than the docstring gave: a sync needing several hundred more calls waits 17.6s for each of them, which is hours in aggregate even though each wait is short.
36
+
37
+ - **Documented what `per_minute` and `per_day` actually guarantee.** A token bucket's configured value is both its capacity and its refill rate, and a fresh bucket starts full — so the first window can spend the capacity *and* everything refilling during it. Measured: `per_minute: 55` allows **109 calls in the first 60 seconds**, not 55. The `Choosing limits` table recommends exactly that value as headroom under Xero's 60, which it is not at a cold start; steady state does converge on the configured rate. Halve the value if you need a hard ceiling in any single window. Both behaviours now have specs so a future change is deliberate.
38
+
39
+ - Noted that the two `retry_after` sources are orders of magnitude apart — Xero's reported wait on a daily 429 can be long, while `DailyLimitExhausted` is always seconds — and that anything recording a durable back-off should tag which one it came from.
40
+ - Strengthened the XML-migration guidance in `docs/client.md` to cover **type** divergence, which it previously called "usually fine". XML has no types, so a boolean arrives as the string `"false"` — truthy in Ruby — while JSON sends a real `false`. A reader using plain truthiness rather than a cast has been doing the opposite of what it reads as, and moving to kiwi silently reverses it at cutover. Reported by a consumer who found years of statements going to contact people explicitly marked not to receive them. That is now the third documented axis of divergence, after nesting and key names.
41
+
3
42
  ## [0.7.0] - 2026-09-27
4
43
 
5
44
  ### Added
@@ -61,7 +61,7 @@ fields Xero returns:
61
61
  | `reference` | `String` | Additional reference number (ACCRECCREDIT only). |
62
62
  | `sent_to_contact` | `Boolean` | Whether the credit note has been sent to the contact. |
63
63
  | `remaining_credit` | `Numeric` | The remaining credit balance. |
64
- | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, `applied_amount`, `date`, `is_deleted`, and an `invoice` reference. |
64
+ | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, the allocated value (as both `amount` and `applied_amount` — see below), `date`, `is_deleted`, and an `invoice` reference. |
65
65
  | `branding_theme_id` | `String` | The branding theme ID applied to the credit note. |
66
66
  | `has_attachments` | `Boolean` | Whether the credit note has attachments. |
67
67
 
@@ -95,3 +95,25 @@ credit_notes = client.credit_notes(tenant_id)
95
95
  with_credit = credit_notes.select { |cn| cn.remaining_credit.to_f > 0 }
96
96
  with_credit.each { |cn| puts "#{cn.credit_note_number}: #{cn.remaining_credit} remaining" }
97
97
  ```
98
+
99
+ ## The allocated value: `amount` and `applied_amount`
100
+
101
+ Xero sends the allocated value under **different keys in different
102
+ representations**:
103
+
104
+ | Representation | Key |
105
+ |---|---|
106
+ | JSON — what this client requests | `Amount` |
107
+ | XML — what other Xero clients get | `AppliedAmount` |
108
+
109
+ Kiwi models both and both readers resolve to whichever one arrived, so
110
+ `allocation.amount` and `allocation.applied_amount` return the same value and
111
+ neither is nil while the other holds one. `to_h` reports it under both keys;
112
+ `raw` shows which key Xero actually sent.
113
+
114
+ This is worth knowing if you are migrating from a client that spoke XML and
115
+ have stored payloads: your old rows say `AppliedAmount` and Xero's JSON says
116
+ `Amount`, for the same field on the same record. Key names do not reliably
117
+ carry across the two representations — an assumption that has produced two
118
+ separate silent-nil bugs here, each one an importer writing `0.0` into every
119
+ allocated amount with nothing raising.
@@ -57,7 +57,7 @@ fields Xero returns:
57
57
  | `currency_code` | `String` | The currency code (e.g. `"NZD"`). |
58
58
  | `currency_rate` | `String` | The currency rate (1.0 for base currency). |
59
59
  | `remaining_credit` | `String` | The remaining credit balance. |
60
- | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, `applied_amount`, `date`, `is_deleted`, and an `invoice` reference. |
60
+ | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, the allocated value (as both `amount` and `applied_amount` — see below), `date`, `is_deleted`, and an `invoice` reference. |
61
61
  | `payments` | `Array<XeroKiwi::Accounting::Payment>` | Payment records (references). See [Payments](payment.md). |
62
62
  | `has_attachments` | `Boolean` | Whether the overpayment has attachments. |
63
63
  | `reference` | `String` | Reference for the overpayment. |
@@ -58,7 +58,7 @@ fields Xero returns:
58
58
  | `currency_rate` | `String` | The currency rate (1.0 for base currency). |
59
59
  | `invoice_number` | `String` | The invoice number (for receive prepayments only). |
60
60
  | `remaining_credit` | `String` | The remaining credit balance. |
61
- | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, `applied_amount`, `date`, `is_deleted`, and an `invoice` reference. |
61
+ | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, the allocated value (as both `amount` and `applied_amount` — see below), `date`, `is_deleted`, and an `invoice` reference. |
62
62
  | `payments` | `Array<XeroKiwi::Accounting::Payment>` | Payment records (references). See [Payments](payment.md). |
63
63
  | `has_attachments` | `Boolean` | Whether the prepayment has attachments. |
64
64
  | `fully_paid_on_date` | `Time` | When the prepayment was fully allocated, parsed as UTC. |
@@ -84,9 +84,20 @@ single `TrackingCategory`, or `nil` if the response is empty.
84
84
  | `tracking_option_id` | `String` | The unique Xero identifier for the option. |
85
85
  | `name` | `String` | The option's display name (e.g. "Eastside"). |
86
86
  | `status` | `String` | `"ACTIVE"` or `"DELETED"`. |
87
+ | `is_active` | `Boolean` | Xero's boolean equivalent of `status`. |
88
+ | `is_archived` | `Boolean` | Whether the option has been archived. |
89
+ | `is_deleted` | `Boolean` | Whether the option has been deleted. |
90
+ | `has_validation_errors` | `Boolean` | Whether Xero flagged validation errors on the option. |
87
91
 
88
92
  `#active?` is a shorthand for `status == "ACTIVE"`.
89
93
 
94
+ Xero sends both `Status` and those booleans, and they overlap. On an active
95
+ option they agree — whether they diverge on an archived or deleted one is
96
+ unconfirmed, which is why `#active?` reads `status` and the booleans are left
97
+ for you to interpret. If you need to distinguish archived from deleted,
98
+ `is_archived` and `is_deleted` are the ones to read; `status` collapses both
99
+ into `"DELETED"`.
100
+
90
101
  ## The Tracking object
91
102
 
92
103
  The assignment shape, nested on line items and contacts.
@@ -40,7 +40,8 @@ Xero returns:
40
40
 
41
41
  | Attribute | Type | What it is |
42
42
  |-----------|------|------------|
43
- | `user_id` | `String` | The unique Xero identifier for the user. |
43
+ | `user_id` | `String` | Xero's identifier for the user **within this organisation**. |
44
+ | `global_user_id` | `String` | Xero's identifier for the person **across all organisations**. Differs from `user_id`, and is what an OIDC `id_token` subject corresponds to. |
44
45
  | `email_address` | `String` | The user's email address. |
45
46
  | `first_name` | `String` | The user's first name. |
46
47
  | `last_name` | `String` | The user's last name. |
data/docs/client.md CHANGED
@@ -113,6 +113,22 @@ Depend on these; they won't change without a major version.
113
113
  The one thing deliberately *not* guaranteed is the presence of `raw` on
114
114
  nested objects, per the bullet above.
115
115
 
116
+ **This is where `raw` and the typed attributes differ on purpose.** Xero
117
+ sends `""` for a text field with no value; the modelled attribute reads it
118
+ as `nil`, while `raw` keeps the `""` exactly as it arrived:
119
+
120
+ ```ruby
121
+ theme.logo_url # => nil
122
+ theme.raw["LogoUrl"] # => ""
123
+ ```
124
+
125
+ That empty string is otherwise easy to carry into a database column, where
126
+ it quietly changes what queries match — a `where.not(logo_url: nil)` starts
127
+ returning rows with no logo. `type: :date` has always made this call;
128
+ strings doing otherwise was an inconsistency. Only exactly `""` is
129
+ affected: `" "` is left alone, because trimming it would be editorialising
130
+ on a value rather than recognising an absent one.
131
+
116
132
  ### Why nested objects don't carry raw
117
133
 
118
134
  Two reasons, both worth knowing if you're tempted to ask for it.
@@ -148,6 +164,18 @@ contact with one person parses to a Hash and a contact with two parses to
148
164
  an Array, from the same endpoint. Code written against that has a
149
165
  normalising step somewhere, whether or not its author knew why.
150
166
 
167
+ **Key names can differ too, not only nesting.** It is tempting to assume the
168
+ two representations agree on field names and diverge only in structure. They
169
+ don't. An allocation's value is `Amount` in JSON and `AppliedAmount` in XML —
170
+ same field, same record, different name — and nothing in Xero's
171
+ documentation says so. Two separate silent-nil bugs in this gem came from
172
+ assuming otherwise, each one an importer writing `0.0` into every allocated
173
+ amount with nothing raising.
174
+
175
+ So when you check a field against stored XML-era payloads, you are checking
176
+ its name as well as its shape. If a modelled attribute comes back nil
177
+ against real JSON, suspect the key before suspecting the data.
178
+
151
179
  No client setting reproduces these shapes — they're artefacts of an XML
152
180
  parse kiwi doesn't do. But the remedy isn't the same everywhere, and it's
153
181
  worth sorting your readers into two piles before planning the work.
@@ -166,10 +194,26 @@ reads are the same in both representations, only the *writer* changes:
166
194
  stop unwrapping, store the array. Existing rows stay readable, and you
167
195
  skip a migration entirely.
168
196
 
169
- Type coercion is usually fine in that second pile too. XML gives you
170
- strings — `"false"` rather than `false` — and if the reader hands that to
171
- something like ActiveModel's boolean cast, both the old string and the new
172
- real boolean land on the same value.
197
+ **Types change too, and booleans are where it bites.** XML has no types, so
198
+ everything arrives as a string: `"false"`, not `false`. JSON gives you the
199
+ real thing. If your reader passes the value through a caster —
200
+ ActiveModel's boolean cast, say — both land on the same result and you'll
201
+ never notice. If it relies on plain Ruby truthiness, the two are opposites:
202
+
203
+ ```ruby
204
+ "false" ? :yes : :no # => :yes ← every non-empty string is truthy
205
+ false ? :yes : :no # => :no
206
+ ```
207
+
208
+ A reader doing `select(&:included)` over an XML-derived `"false"` has been
209
+ selecting the records it was meant to exclude, for as long as that code has
210
+ existed. Moving to kiwi *fixes* it — which means the behaviour changes at
211
+ cutover, in a way your users may see. Find those readers before you switch,
212
+ not after: grep for boolean-ish fields consumed without a cast.
213
+
214
+ This is the third axis on which the two representations differ, after
215
+ nesting and key names. Treat "it's the same field, so it's the same value"
216
+ as the assumption to check rather than the one to rely on.
173
217
 
174
218
  One piece of luck worth knowing about: the two piles tend to fail
175
219
  differently. A `dig` that misses returns nil and writes a blank record
@@ -116,6 +116,21 @@ Three things worth knowing when you write this:
116
116
  - **`tenant_id` is on the exception for a reason.** Don't infer it from
117
117
  surrounding context — that breaks the moment one client serves more than
118
118
  one tenant.
119
+ - **The two `retry_after` values are orders of magnitude apart.** Keep them
120
+ distinguishable, which is what the `source:` in the example above is for.
121
+
122
+ | Source | Whose limit | Typical magnitude |
123
+ |---|---|---|
124
+ | `RateLimitError` | Xero's, reported by Xero | seconds to hours, and on a daily 429 it can be genuinely long |
125
+ | `Throttle::DailyLimitExhausted` | yours, from the configured bucket | **seconds** — at `per_day: 4_900`, 17.6s, the time for one token to accrue |
126
+ | `Throttle::Timeout` | yours, per-minute bucket | seconds |
127
+
128
+ The throttle's day bucket trickles rather than resetting on a boundary, so
129
+ `DailyLimitExhausted#retry_after` is the wait for a *single* token, not the
130
+ wait until your daily allowance renews. It's enough to resume, not enough to
131
+ finish — which is exactly why the job should re-enqueue rather than sleep.
132
+ If your UI shows a countdown, showing 17 seconds when the sync has hundreds
133
+ of calls left to make will read as broken.
119
134
 
120
135
  `XeroKiwi::Throttle::Timeout` carries `tenant_id` and `retry_after` too, if
121
136
  you want to treat a per-minute wait that exceeded `max_wait` as a back-off
data/docs/throttling.md CHANGED
@@ -117,9 +117,34 @@ Pick values *below* Xero's defaults:
117
117
  | 5,000 calls/day per tenant | `per_day: 4,700` – `4,900` |
118
118
 
119
119
  The exact number depends on how much you care about the occasional 429 vs.
120
- maximising throughput. If your job batches run for hours, lean conservative —
121
- the daily limit resets on Xero's clock, not yours, and the first few
122
- minutes after "daily reset" can be ambiguous.
120
+ maximising throughput.
121
+
122
+ ### What the numbers actually guarantee
123
+
124
+ This is a token bucket, so your configured value is **two things at once**:
125
+ the bucket's capacity, and its refill rate. A fresh bucket starts full, so
126
+ over the *first* window you can spend the full capacity **and** everything
127
+ that refills during it — close to double.
128
+
129
+ Measured, from a fresh bucket:
130
+
131
+ | Setting | Calls in the first 60s | Sustained after that |
132
+ |---|---|---|
133
+ | `per_minute: 55` | 109 | 55/min |
134
+ | `per_minute: 10` | 19 | 10/min |
135
+ | `per_minute: 5` | 9 | 5/min |
136
+
137
+ So `per_minute: 55` is not a promise that you will never exceed 60 in a
138
+ minute — a cold start can reach 109, and Xero will 429 some of those. The
139
+ reactive retry layer is what catches them; that's why both layers exist and
140
+ neither replaces the other.
141
+
142
+ Steady state is what the setting really controls, and there it converges on
143
+ exactly the value you configured. If you need a hard ceiling within any
144
+ single 60-second window, halve it: `per_minute: 30` keeps even a cold-start
145
+ burst under Xero's 60.
146
+
147
+ The same applies to `per_day`, on a 24-hour scale.
123
148
 
124
149
  ## Per-minute vs per-day failure modes
125
150
 
@@ -133,19 +158,32 @@ probably too many concurrent workers for the configured `per_minute`.
133
158
 
134
159
  **Per-day:** the limiter raises `XeroKiwi::Throttle::DailyLimitExhausted`
135
160
  immediately, with `retry_after` in seconds and the `tenant_id` it relates to.
136
- Sleeping for hours is never the right move in a Sidekiq worker, so the caller
137
- has to decide:
161
+
162
+ `retry_after` here is **seconds, not hours**. The day bucket trickles like
163
+ the minute one rather than resetting on a boundary, so at `per_day: 4_900` a
164
+ token accrues every 17.6 seconds and that is what you get back. There is no
165
+ reset in the arithmetic.
166
+
167
+ Re-enqueueing rather than blocking is still right, but for a different
168
+ reason than the wait length. A sync needing several hundred more calls would
169
+ wait 17.6s for *each* of them — short individually, hours in aggregate. That
170
+ is the thing a worker should not sit through.
138
171
 
139
172
  ```ruby
140
173
  begin
141
174
  client.invoices(tenant_id)
142
175
  rescue XeroKiwi::Throttle::DailyLimitExhausted => e
143
- # Re-enqueue the job for tomorrow. `retry_after` is seconds until the
144
- # bucket has at least one token.
176
+ # `retry_after` is seconds until the bucket has one token — enough to
177
+ # resume, not enough to finish. Re-enqueue rather than sleep.
145
178
  MyJob.perform_in(e.retry_after, org_id)
146
179
  end
147
180
  ```
148
181
 
182
+ Don't conflate this with Xero's own `Retry-After` on a daily 429. That one
183
+ reflects Xero's limit rather than your configured one and can be genuinely
184
+ long. The two are orders of magnitude apart, so anything recording a durable
185
+ back-off should keep them distinguishable — tag the source.
186
+
149
187
  Both throttle exceptions carry `tenant_id` and `retry_after`, which mirrors
150
188
  the `XeroKiwi::RateLimitError` shape the retry layer raises after exhausting
151
189
  retries on a 429 — so one rescue can cover both, and code handling them looks
@@ -5,14 +5,19 @@ module XeroKiwi
5
5
  # Represents an allocation of a credit note, prepayment, or overpayment
6
6
  # against an invoice.
7
7
  #
8
- # Xero names the allocated value differently depending on direction: the
9
- # allocation *request* body takes "Amount", while every *response* — and
10
- # allocations only ever reach this gem embedded in a CreditNote,
11
- # Prepayment or Overpayment response — carries "AppliedAmount". This
12
- # class was originally modelled off the request shape, so `amount`
13
- # silently returned nil for every allocation the gem could actually
14
- # produce. `#amount` is now an alias of `#applied_amount` so callers who
15
- # reached for the obvious name get the value they meant.
8
+ # THE ALLOCATED VALUE ARRIVES UNDER DIFFERENT KEYS IN DIFFERENT XERO
9
+ # REPRESENTATIONS, and getting it wrong writes zeroes into money:
10
+ #
11
+ # JSON (what this client requests): "Amount"
12
+ # XML (what other clients get): "AppliedAmount"
13
+ #
14
+ # That is not a request-vs-response split, which is what 0.6.0 assumed
15
+ # when it renamed `amount` to `applied_amount` — an assumption drawn from
16
+ # payloads a legacy XML client had stored, and wrong for this JSON-only
17
+ # client. Both keys are modelled now, and `#amount` and `#applied_amount`
18
+ # each resolve to whichever one Xero populated, so neither can be nil
19
+ # when the other holds a value. This has been got wrong twice in opposite
20
+ # directions; reading both is cheaper than being certain.
16
21
  #
17
22
  # See: https://developer.xero.com/documentation/api/accounting/overpayments
18
23
  class Allocation
@@ -21,15 +26,18 @@ module XeroKiwi
21
26
  identity :allocation_id
22
27
 
23
28
  attribute :allocation_id, xero: "AllocationID", type: :guid
29
+ attribute :amount, xero: "Amount", type: :decimal
24
30
  attribute :applied_amount, xero: "AppliedAmount", type: :decimal
25
31
  attribute :date, xero: "Date", type: :date
26
32
  attribute :invoice, xero: "Invoice", type: :object, of: Invoice, reference: true
27
33
  attribute :is_deleted, xero: "IsDeleted", type: :bool
28
34
 
29
- # Kept for callers written against the pre-0.6.0 attribute name. It
30
- # could only ever have returned nil, so nothing that relied on a real
31
- # value is affected.
32
- def amount = applied_amount
35
+ # Both names are public API and both resolve to the same value. `to_h`
36
+ # therefore reports it under both keys; `raw` still shows exactly which
37
+ # one Xero sent.
38
+ def amount = @amount.nil? ? @applied_amount : @amount
39
+
40
+ def applied_amount = @applied_amount.nil? ? @amount : @applied_amount
33
41
  end
34
42
  end
35
43
  end
@@ -20,25 +20,41 @@ module XeroKiwi
20
20
  module Hydrator
21
21
  module_function
22
22
 
23
- def call(raw, spec) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
23
+ def call(raw, spec)
24
24
  return spec[:hydrate].call(raw) if spec[:hydrate]
25
25
  return [] if spec[:type] == :collection && raw.nil?
26
26
  return nil if raw.nil?
27
27
 
28
+ hydrate_typed(raw, spec)
29
+ end
30
+
31
+ def hydrate_typed(raw, spec)
28
32
  case spec[:type]
29
- when :string, :enum, :guid, :bool, :decimal
30
- raw
31
- when :date
32
- parse_time(raw)
33
- when :object
34
- build_object(raw, spec)
35
- when :collection
36
- raw.map { |item| build_object(item, spec) }
37
- else
38
- raise ArgumentError, "unknown attribute type: #{spec[:type].inspect}"
33
+ when :string, :enum, :guid then blank_to_nil(raw)
34
+ when :bool, :decimal then raw
35
+ when :date then parse_time(raw)
36
+ when :object then build_object(raw, spec)
37
+ when :collection then raw.map { |item| build_object(item, spec) }
38
+ else raise ArgumentError, "unknown attribute type: #{spec[:type].inspect}"
39
39
  end
40
40
  end
41
41
 
42
+ # Xero sends "" for a text field that has no value, where its XML
43
+ # representation produced nil. Left alone, that empty string reaches a
44
+ # database column and quietly changes what queries match — a
45
+ # `where.not(logo_url: nil)` starts returning rows with no logo.
46
+ #
47
+ # Only exactly "" becomes nil. Whitespace is left alone: " " may be
48
+ # deliberate, and trimming it would be editorialising on a value
49
+ # rather than recognising an absent one. `raw` is untouched either
50
+ # way, so the original payload is always recoverable.
51
+ #
52
+ # `:date` has always done this (see parse_time); strings behaving
53
+ # differently was an inconsistency, not a principle.
54
+ def blank_to_nil(value)
55
+ value == "" ? nil : value
56
+ end
57
+
42
58
  # Xero uses two timestamp formats depending on the endpoint:
43
59
  #
44
60
  # ISO 8601: "2019-07-09T23:40:30.1833130" (connections API)
@@ -5,6 +5,11 @@ module XeroKiwi
5
5
  # One selectable option within a tracking category, as returned nested in
6
6
  # the /TrackingCategories response.
7
7
  #
8
+ # Xero sends both `Status` and a set of booleans that overlap it. They
9
+ # agree on an active option; whether they diverge on an archived or
10
+ # deleted one is unconfirmed, so `#active?` stays on `Status` and the
11
+ # booleans are exposed for callers who need the distinction.
12
+ #
8
13
  # See: https://developer.xero.com/documentation/api/accounting/trackingcategories
9
14
  class TrackingOption
10
15
  include Resource
@@ -12,9 +17,13 @@ module XeroKiwi
12
17
  payload_key "Options"
13
18
  identity :tracking_option_id
14
19
 
15
- attribute :tracking_option_id, xero: "TrackingOptionID", type: :guid
16
- attribute :name, xero: "Name", query: true
17
- attribute :status, xero: "Status", type: :enum, query: true
20
+ attribute :tracking_option_id, xero: "TrackingOptionID", type: :guid
21
+ attribute :name, xero: "Name", query: true
22
+ attribute :status, xero: "Status", type: :enum, query: true
23
+ attribute :is_active, xero: "IsActive", type: :bool
24
+ attribute :is_archived, xero: "IsArchived", type: :bool
25
+ attribute :is_deleted, xero: "IsDeleted", type: :bool
26
+ attribute :has_validation_errors, xero: "HasValidationErrors", type: :bool
18
27
 
19
28
  def active? = status == "ACTIVE"
20
29
  end
@@ -11,7 +11,13 @@ module XeroKiwi
11
11
  payload_key "Users"
12
12
  identity :user_id
13
13
 
14
- attribute :user_id, xero: "UserID", type: :guid
14
+ # Xero returns two distinct identifiers. UserID is scoped to the
15
+ # organisation; GlobalUserID identifies the person across every
16
+ # organisation they belong to, and is what an OIDC id_token subject
17
+ # corresponds to. They differ for every user — key membership records
18
+ # on the one your own user table uses.
19
+ attribute :user_id, xero: "UserID", type: :guid
20
+ attribute :global_user_id, xero: "GlobalUserID", type: :guid
15
21
  attribute :email_address, xero: "EmailAddress", query: true
16
22
  attribute :first_name, xero: "FirstName", query: true
17
23
  attribute :last_name, xero: "LastName", query: true
@@ -935,17 +935,33 @@ module XeroKiwi
935
935
 
936
936
  @store.record(
937
937
  tenant_id,
938
- day: to_i_or_nil(headers["X-DayLimit-Remaining"]),
939
- minute: to_i_or_nil(headers["X-MinLimit-Remaining"]),
940
- app_minute: to_i_or_nil(headers["X-AppMinLimit-Remaining"])
938
+ day: header_int(headers, "X-DayLimit-Remaining"),
939
+ minute: header_int(headers, "X-MinLimit-Remaining"),
940
+ app_minute: header_int(headers, "X-AppMinLimit-Remaining")
941
941
  )
942
942
  end
943
943
 
944
944
  private
945
945
 
946
- def to_i_or_nil(value)
946
+ # Faraday's own header container is case-insensitive, so a direct
947
+ # lookup is right in production. It is not right everywhere: a plain
948
+ # Hash is case-sensitive, and Xero's headers come back cased
949
+ # differently depending on who recorded them — a VCR cassette holds
950
+ # `X-Daylimit-Remaining`, not `X-DayLimit-Remaining`. Missing them
951
+ # would record nothing, and `day_below?` answers false when nothing is
952
+ # known, so the failure is a quota check that silently measures
953
+ # nothing. Scan on miss rather than rely on the container's manners.
954
+ def header_int(headers, name)
955
+ value = headers[name]
956
+ value = scan_for(headers, name) if value.nil?
957
+
947
958
  value.nil? || value.to_s.empty? ? nil : value.to_i
948
959
  end
960
+
961
+ def scan_for(headers, name)
962
+ key = headers.keys.find { |candidate| candidate.to_s.casecmp?(name) }
963
+ key && headers[key]
964
+ end
949
965
  end
950
966
 
951
967
  # Faraday middleware that maps non-2xx responses onto our exception
@@ -28,10 +28,25 @@ module XeroKiwi
28
28
  end
29
29
  end
30
30
 
31
- # Raised immediately (no sleep) when the per-day bucket is exhausted. The
32
- # wait until reset is typically measured in hours, so blocking the caller
33
- # is the wrong move — re-enqueue the job at `retry_after` instead. Shape
34
- # mirrors RateLimitError so existing Xero rate-limit handling applies.
31
+ # Raised immediately (no sleep) when the per-day bucket is exhausted.
32
+ #
33
+ # `retry_after` is the wait for ONE token, and it is seconds, not hours —
34
+ # the day bucket trickles like the minute bucket rather than resetting on
35
+ # a boundary. At `per_day: 4_900` a token accrues every 17.6s
36
+ # (86_400_000ms / 4_900). There is no reset in the arithmetic.
37
+ #
38
+ # Re-enqueueing rather than blocking is still the right move, but for a
39
+ # different reason than the wait length: a sync needing several hundred
40
+ # more calls would sleep 17.6s for each of them, which is hours in
41
+ # aggregate even though each individual wait is short.
42
+ #
43
+ # Do not conflate this with Xero's own `Retry-After` on a daily 429,
44
+ # which reflects Xero's limit rather than your configured one and can be
45
+ # genuinely long. Anything recording a durable back-off should keep the
46
+ # two distinguishable — see docs/retries-and-rate-limits.md.
47
+ #
48
+ # Shape mirrors RateLimitError so existing Xero rate-limit handling
49
+ # applies.
35
50
  class DailyLimitExhausted < Error
36
51
  attr_reader :retry_after, :tenant_id
37
52
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module XeroKiwi
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/llms-full.txt CHANGED
@@ -436,6 +436,22 @@ Depend on these; they won't change without a major version.
436
436
  The one thing deliberately *not* guaranteed is the presence of `raw` on
437
437
  nested objects, per the bullet above.
438
438
 
439
+ **This is where `raw` and the typed attributes differ on purpose.** Xero
440
+ sends `""` for a text field with no value; the modelled attribute reads it
441
+ as `nil`, while `raw` keeps the `""` exactly as it arrived:
442
+
443
+ ```ruby
444
+ theme.logo_url # => nil
445
+ theme.raw["LogoUrl"] # => ""
446
+ ```
447
+
448
+ That empty string is otherwise easy to carry into a database column, where
449
+ it quietly changes what queries match — a `where.not(logo_url: nil)` starts
450
+ returning rows with no logo. `type: :date` has always made this call;
451
+ strings doing otherwise was an inconsistency. Only exactly `""` is
452
+ affected: `" "` is left alone, because trimming it would be editorialising
453
+ on a value rather than recognising an absent one.
454
+
439
455
  ### Why nested objects don't carry raw
440
456
 
441
457
  Two reasons, both worth knowing if you're tempted to ask for it.
@@ -471,6 +487,18 @@ contact with one person parses to a Hash and a contact with two parses to
471
487
  an Array, from the same endpoint. Code written against that has a
472
488
  normalising step somewhere, whether or not its author knew why.
473
489
 
490
+ **Key names can differ too, not only nesting.** It is tempting to assume the
491
+ two representations agree on field names and diverge only in structure. They
492
+ don't. An allocation's value is `Amount` in JSON and `AppliedAmount` in XML —
493
+ same field, same record, different name — and nothing in Xero's
494
+ documentation says so. Two separate silent-nil bugs in this gem came from
495
+ assuming otherwise, each one an importer writing `0.0` into every allocated
496
+ amount with nothing raising.
497
+
498
+ So when you check a field against stored XML-era payloads, you are checking
499
+ its name as well as its shape. If a modelled attribute comes back nil
500
+ against real JSON, suspect the key before suspecting the data.
501
+
474
502
  No client setting reproduces these shapes — they're artefacts of an XML
475
503
  parse kiwi doesn't do. But the remedy isn't the same everywhere, and it's
476
504
  worth sorting your readers into two piles before planning the work.
@@ -489,10 +517,26 @@ reads are the same in both representations, only the *writer* changes:
489
517
  stop unwrapping, store the array. Existing rows stay readable, and you
490
518
  skip a migration entirely.
491
519
 
492
- Type coercion is usually fine in that second pile too. XML gives you
493
- strings — `"false"` rather than `false` — and if the reader hands that to
494
- something like ActiveModel's boolean cast, both the old string and the new
495
- real boolean land on the same value.
520
+ **Types change too, and booleans are where it bites.** XML has no types, so
521
+ everything arrives as a string: `"false"`, not `false`. JSON gives you the
522
+ real thing. If your reader passes the value through a caster —
523
+ ActiveModel's boolean cast, say — both land on the same result and you'll
524
+ never notice. If it relies on plain Ruby truthiness, the two are opposites:
525
+
526
+ ```ruby
527
+ "false" ? :yes : :no # => :yes ← every non-empty string is truthy
528
+ false ? :yes : :no # => :no
529
+ ```
530
+
531
+ A reader doing `select(&:included)` over an XML-derived `"false"` has been
532
+ selecting the records it was meant to exclude, for as long as that code has
533
+ existed. Moving to kiwi *fixes* it — which means the behaviour changes at
534
+ cutover, in a way your users may see. Find those readers before you switch,
535
+ not after: grep for boolean-ish fields consumed without a cast.
536
+
537
+ This is the third axis on which the two representations differ, after
538
+ nesting and key names. Treat "it's the same field, so it's the same value"
539
+ as the assumption to check rather than the one to rely on.
496
540
 
497
541
  One piece of luck worth knowing about: the two piles tend to fail
498
542
  differently. A `dig` that misses returns nil and writes a blank record
@@ -2394,7 +2438,8 @@ Xero returns:
2394
2438
 
2395
2439
  | Attribute | Type | What it is |
2396
2440
  |-----------|------|------------|
2397
- | `user_id` | `String` | The unique Xero identifier for the user. |
2441
+ | `user_id` | `String` | Xero's identifier for the user **within this organisation**. |
2442
+ | `global_user_id` | `String` | Xero's identifier for the person **across all organisations**. Differs from `user_id`, and is what an OIDC `id_token` subject corresponds to. |
2398
2443
  | `email_address` | `String` | The user's email address. |
2399
2444
  | `first_name` | `String` | The user's first name. |
2400
2445
  | `last_name` | `String` | The user's last name. |
@@ -2530,7 +2575,7 @@ fields Xero returns:
2530
2575
  | `reference` | `String` | Additional reference number (ACCRECCREDIT only). |
2531
2576
  | `sent_to_contact` | `Boolean` | Whether the credit note has been sent to the contact. |
2532
2577
  | `remaining_credit` | `Numeric` | The remaining credit balance. |
2533
- | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, `applied_amount`, `date`, `is_deleted`, and an `invoice` reference. |
2578
+ | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, the allocated value (as both `amount` and `applied_amount` — see below), `date`, `is_deleted`, and an `invoice` reference. |
2534
2579
  | `branding_theme_id` | `String` | The branding theme ID applied to the credit note. |
2535
2580
  | `has_attachments` | `Boolean` | Whether the credit note has attachments. |
2536
2581
 
@@ -2565,6 +2610,28 @@ with_credit = credit_notes.select { |cn| cn.remaining_credit.to_f > 0 }
2565
2610
  with_credit.each { |cn| puts "#{cn.credit_note_number}: #{cn.remaining_credit} remaining" }
2566
2611
  ```
2567
2612
 
2613
+ ## The allocated value: `amount` and `applied_amount`
2614
+
2615
+ Xero sends the allocated value under **different keys in different
2616
+ representations**:
2617
+
2618
+ | Representation | Key |
2619
+ |---|---|
2620
+ | JSON — what this client requests | `Amount` |
2621
+ | XML — what other Xero clients get | `AppliedAmount` |
2622
+
2623
+ Kiwi models both and both readers resolve to whichever one arrived, so
2624
+ `allocation.amount` and `allocation.applied_amount` return the same value and
2625
+ neither is nil while the other holds one. `to_h` reports it under both keys;
2626
+ `raw` shows which key Xero actually sent.
2627
+
2628
+ This is worth knowing if you are migrating from a client that spoke XML and
2629
+ have stored payloads: your old rows say `AppliedAmount` and Xero's JSON says
2630
+ `Amount`, for the same field on the same record. Key names do not reliably
2631
+ carry across the two representations — an assumption that has produced two
2632
+ separate silent-nil bugs here, each one an importer writing `0.0` into every
2633
+ allocated amount with nothing raising.
2634
+
2568
2635
 
2569
2636
  ================================================================================
2570
2637
  FILE: docs/accounting/invoice.md
@@ -2874,7 +2941,7 @@ fields Xero returns:
2874
2941
  | `currency_code` | `String` | The currency code (e.g. `"NZD"`). |
2875
2942
  | `currency_rate` | `String` | The currency rate (1.0 for base currency). |
2876
2943
  | `remaining_credit` | `String` | The remaining credit balance. |
2877
- | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, `applied_amount`, `date`, `is_deleted`, and an `invoice` reference. |
2944
+ | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, the allocated value (as both `amount` and `applied_amount` — see below), `date`, `is_deleted`, and an `invoice` reference. |
2878
2945
  | `payments` | `Array<XeroKiwi::Accounting::Payment>` | Payment records (references). See [Payments](payment.md). |
2879
2946
  | `has_attachments` | `Boolean` | Whether the overpayment has attachments. |
2880
2947
  | `reference` | `String` | Reference for the overpayment. |
@@ -2975,7 +3042,7 @@ fields Xero returns:
2975
3042
  | `currency_rate` | `String` | The currency rate (1.0 for base currency). |
2976
3043
  | `invoice_number` | `String` | The invoice number (for receive prepayments only). |
2977
3044
  | `remaining_credit` | `String` | The remaining credit balance. |
2978
- | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, `applied_amount`, `date`, `is_deleted`, and an `invoice` reference. |
3045
+ | `allocations` | `Array<XeroKiwi::Accounting::Allocation>` | Allocations to invoices. Each has `allocation_id`, the allocated value (as both `amount` and `applied_amount` — see below), `date`, `is_deleted`, and an `invoice` reference. |
2979
3046
  | `payments` | `Array<XeroKiwi::Accounting::Payment>` | Payment records (references). See [Payments](payment.md). |
2980
3047
  | `has_attachments` | `Boolean` | Whether the prepayment has attachments. |
2981
3048
  | `fully_paid_on_date` | `Time` | When the prepayment was fully allocated, parsed as UTC. |
@@ -3216,9 +3283,20 @@ single `TrackingCategory`, or `nil` if the response is empty.
3216
3283
  | `tracking_option_id` | `String` | The unique Xero identifier for the option. |
3217
3284
  | `name` | `String` | The option's display name (e.g. "Eastside"). |
3218
3285
  | `status` | `String` | `"ACTIVE"` or `"DELETED"`. |
3286
+ | `is_active` | `Boolean` | Xero's boolean equivalent of `status`. |
3287
+ | `is_archived` | `Boolean` | Whether the option has been archived. |
3288
+ | `is_deleted` | `Boolean` | Whether the option has been deleted. |
3289
+ | `has_validation_errors` | `Boolean` | Whether Xero flagged validation errors on the option. |
3219
3290
 
3220
3291
  `#active?` is a shorthand for `status == "ACTIVE"`.
3221
3292
 
3293
+ Xero sends both `Status` and those booleans, and they overlap. On an active
3294
+ option they agree — whether they diverge on an archived or deleted one is
3295
+ unconfirmed, which is why `#active?` reads `status` and the booleans are left
3296
+ for you to interpret. If you need to distinguish archived from deleted,
3297
+ `is_archived` and `is_deleted` are the ones to read; `status` collapses both
3298
+ into `"DELETED"`.
3299
+
3222
3300
  ## The Tracking object
3223
3301
 
3224
3302
  The assignment shape, nested on line items and contacts.
@@ -3895,6 +3973,21 @@ Three things worth knowing when you write this:
3895
3973
  - **`tenant_id` is on the exception for a reason.** Don't infer it from
3896
3974
  surrounding context — that breaks the moment one client serves more than
3897
3975
  one tenant.
3976
+ - **The two `retry_after` values are orders of magnitude apart.** Keep them
3977
+ distinguishable, which is what the `source:` in the example above is for.
3978
+
3979
+ | Source | Whose limit | Typical magnitude |
3980
+ |---|---|---|
3981
+ | `RateLimitError` | Xero's, reported by Xero | seconds to hours, and on a daily 429 it can be genuinely long |
3982
+ | `Throttle::DailyLimitExhausted` | yours, from the configured bucket | **seconds** — at `per_day: 4_900`, 17.6s, the time for one token to accrue |
3983
+ | `Throttle::Timeout` | yours, per-minute bucket | seconds |
3984
+
3985
+ The throttle's day bucket trickles rather than resetting on a boundary, so
3986
+ `DailyLimitExhausted#retry_after` is the wait for a *single* token, not the
3987
+ wait until your daily allowance renews. It's enough to resume, not enough to
3988
+ finish — which is exactly why the job should re-enqueue rather than sleep.
3989
+ If your UI shows a countdown, showing 17 seconds when the sync has hundreds
3990
+ of calls left to make will read as broken.
3898
3991
 
3899
3992
  `XeroKiwi::Throttle::Timeout` carries `tenant_id` and `retry_after` too, if
3900
3993
  you want to treat a per-minute wait that exceeded `max_wait` as a back-off
@@ -4229,9 +4322,34 @@ Pick values *below* Xero's defaults:
4229
4322
  | 5,000 calls/day per tenant | `per_day: 4,700` – `4,900` |
4230
4323
 
4231
4324
  The exact number depends on how much you care about the occasional 429 vs.
4232
- maximising throughput. If your job batches run for hours, lean conservative —
4233
- the daily limit resets on Xero's clock, not yours, and the first few
4234
- minutes after "daily reset" can be ambiguous.
4325
+ maximising throughput.
4326
+
4327
+ ### What the numbers actually guarantee
4328
+
4329
+ This is a token bucket, so your configured value is **two things at once**:
4330
+ the bucket's capacity, and its refill rate. A fresh bucket starts full, so
4331
+ over the *first* window you can spend the full capacity **and** everything
4332
+ that refills during it — close to double.
4333
+
4334
+ Measured, from a fresh bucket:
4335
+
4336
+ | Setting | Calls in the first 60s | Sustained after that |
4337
+ |---|---|---|
4338
+ | `per_minute: 55` | 109 | 55/min |
4339
+ | `per_minute: 10` | 19 | 10/min |
4340
+ | `per_minute: 5` | 9 | 5/min |
4341
+
4342
+ So `per_minute: 55` is not a promise that you will never exceed 60 in a
4343
+ minute — a cold start can reach 109, and Xero will 429 some of those. The
4344
+ reactive retry layer is what catches them; that's why both layers exist and
4345
+ neither replaces the other.
4346
+
4347
+ Steady state is what the setting really controls, and there it converges on
4348
+ exactly the value you configured. If you need a hard ceiling within any
4349
+ single 60-second window, halve it: `per_minute: 30` keeps even a cold-start
4350
+ burst under Xero's 60.
4351
+
4352
+ The same applies to `per_day`, on a 24-hour scale.
4235
4353
 
4236
4354
  ## Per-minute vs per-day failure modes
4237
4355
 
@@ -4245,19 +4363,32 @@ probably too many concurrent workers for the configured `per_minute`.
4245
4363
 
4246
4364
  **Per-day:** the limiter raises `XeroKiwi::Throttle::DailyLimitExhausted`
4247
4365
  immediately, with `retry_after` in seconds and the `tenant_id` it relates to.
4248
- Sleeping for hours is never the right move in a Sidekiq worker, so the caller
4249
- has to decide:
4366
+
4367
+ `retry_after` here is **seconds, not hours**. The day bucket trickles like
4368
+ the minute one rather than resetting on a boundary, so at `per_day: 4_900` a
4369
+ token accrues every 17.6 seconds and that is what you get back. There is no
4370
+ reset in the arithmetic.
4371
+
4372
+ Re-enqueueing rather than blocking is still right, but for a different
4373
+ reason than the wait length. A sync needing several hundred more calls would
4374
+ wait 17.6s for *each* of them — short individually, hours in aggregate. That
4375
+ is the thing a worker should not sit through.
4250
4376
 
4251
4377
  ```ruby
4252
4378
  begin
4253
4379
  client.invoices(tenant_id)
4254
4380
  rescue XeroKiwi::Throttle::DailyLimitExhausted => e
4255
- # Re-enqueue the job for tomorrow. `retry_after` is seconds until the
4256
- # bucket has at least one token.
4381
+ # `retry_after` is seconds until the bucket has one token — enough to
4382
+ # resume, not enough to finish. Re-enqueue rather than sleep.
4257
4383
  MyJob.perform_in(e.retry_after, org_id)
4258
4384
  end
4259
4385
  ```
4260
4386
 
4387
+ Don't conflate this with Xero's own `Retry-After` on a daily 429. That one
4388
+ reflects Xero's limit rather than your configured one and can be genuinely
4389
+ long. The two are orders of magnitude apart, so anything recording a durable
4390
+ back-off should keep them distinguishable — tag the source.
4391
+
4261
4392
  Both throttle exceptions carry `tenant_id` and `retry_after`, which mirrors
4262
4393
  the `XeroKiwi::RateLimitError` shape the retry layer raises after exhausting
4263
4394
  retries on a 429 — so one rescue can cover both, and code handling them looks
data/llms.txt CHANGED
@@ -66,5 +66,5 @@ XeroKiwi requires Ruby 3.4.1 or newer. Install with `gem "xero-kiwi"`.
66
66
 
67
67
  - [README](https://raw.githubusercontent.com/douglasgreyling/xero-kiwi/main/README.md): high-level overview, installation, the documentation table of contents, and the development and release process
68
68
  - [Retries and rate limits](https://raw.githubusercontent.com/douglasgreyling/xero-kiwi/main/docs/retries-and-rate-limits.md): how XeroKiwi handles 429s and transient 5xxs, `client.rate_limit(tenant_id)` for remaining quota (process-local), recording a durable per-tenant back-off signal from the `tenant_id` and `retry_after` carried by RateLimitError and the throttle errors, tuning the retry policy, the Faraday middleware ordering, why 500 is deliberately not retried
69
- - [Throttling](https://raw.githubusercontent.com/douglasgreyling/xero-kiwi/main/docs/throttling.md): the Redis-backed per-tenant token bucket for coordinating rate limits across processes, `default_throttle`, `#remaining`, fail-open behaviour, writing a custom limiter
69
+ - [Throttling](https://raw.githubusercontent.com/douglasgreyling/xero-kiwi/main/docs/throttling.md): the Redis-backed per-tenant token bucket for coordinating rate limits across processes, `default_throttle`, what per_minute/per_day actually guarantee (a fresh bucket bursts to roughly double in the first window), `#remaining`, fail-open behaviour, writing a custom limiter
70
70
  - [CHANGELOG](https://raw.githubusercontent.com/douglasgreyling/xero-kiwi/main/CHANGELOG.md): version history
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: xero-kiwi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Douglas Greyling