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 +4 -4
- data/CHANGELOG.md +39 -0
- data/docs/accounting/credit-note.md +23 -1
- data/docs/accounting/overpayment.md +1 -1
- data/docs/accounting/prepayment.md +1 -1
- data/docs/accounting/tracking-category.md +11 -0
- data/docs/accounting/user.md +2 -1
- data/docs/client.md +48 -4
- data/docs/retries-and-rate-limits.md +15 -0
- data/docs/throttling.md +45 -7
- data/lib/xero_kiwi/accounting/allocation.rb +20 -12
- data/lib/xero_kiwi/accounting/hydrator.rb +27 -11
- data/lib/xero_kiwi/accounting/tracking_option.rb +12 -3
- data/lib/xero_kiwi/accounting/user.rb +7 -1
- data/lib/xero_kiwi/client.rb +20 -4
- data/lib/xero_kiwi/throttle.rb +19 -4
- data/lib/xero_kiwi/version.rb +1 -1
- data/llms-full.txt +146 -15
- data/llms.txt +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: deaef4aaa66b0ac56631e391e34e5747c32da981244477ba212ff664cbe41cab
|
|
4
|
+
data.tar.gz: 6b3bd18bf430e1967d59875308e73749a13e3c2acc2279c9e58d065af94451db
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
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.
|
data/docs/accounting/user.md
CHANGED
|
@@ -40,7 +40,8 @@ Xero returns:
|
|
|
40
40
|
|
|
41
41
|
| Attribute | Type | What it is |
|
|
42
42
|
|-----------|------|------------|
|
|
43
|
-
| `user_id` | `String` |
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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.
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
#
|
|
144
|
-
#
|
|
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
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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)
|
|
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
|
|
30
|
-
|
|
31
|
-
when :date
|
|
32
|
-
|
|
33
|
-
when :
|
|
34
|
-
|
|
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,
|
|
16
|
-
attribute :name,
|
|
17
|
-
attribute :status,
|
|
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
|
-
|
|
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
|
data/lib/xero_kiwi/client.rb
CHANGED
|
@@ -935,17 +935,33 @@ module XeroKiwi
|
|
|
935
935
|
|
|
936
936
|
@store.record(
|
|
937
937
|
tenant_id,
|
|
938
|
-
day:
|
|
939
|
-
minute:
|
|
940
|
-
app_minute:
|
|
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
|
-
|
|
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
|
data/lib/xero_kiwi/throttle.rb
CHANGED
|
@@ -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.
|
|
32
|
-
#
|
|
33
|
-
# is the
|
|
34
|
-
#
|
|
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
|
|
data/lib/xero_kiwi/version.rb
CHANGED
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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` |
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
4233
|
-
|
|
4234
|
-
|
|
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
|
-
|
|
4249
|
-
|
|
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
|
-
#
|
|
4256
|
-
#
|
|
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
|