xero-kiwi 0.4.0 → 0.5.1
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 +35 -0
- data/README.md +12 -4
- data/Rakefile +2 -0
- data/docs/accounting/tracking-category.md +150 -0
- data/docs/client.md +92 -0
- data/docs/querying.md +86 -1
- data/docs/retries-and-rate-limits.md +48 -0
- data/docs/throttling.md +27 -1
- data/lib/xero_kiwi/accounting/contact.rb +3 -3
- data/lib/xero_kiwi/accounting/line_item.rb +1 -1
- data/lib/xero_kiwi/accounting/organisation.rb +4 -3
- data/lib/xero_kiwi/accounting/resource.rb +33 -2
- data/lib/xero_kiwi/accounting/tracking.rb +20 -0
- data/lib/xero_kiwi/accounting/tracking_category.rb +14 -5
- data/lib/xero_kiwi/accounting/tracking_option.rb +22 -0
- data/lib/xero_kiwi/client.rb +376 -88
- data/lib/xero_kiwi/page.rb +13 -6
- data/lib/xero_kiwi/rate_limit.rb +90 -0
- data/lib/xero_kiwi/throttle/null_limiter.rb +8 -1
- data/lib/xero_kiwi/throttle/redis_token_bucket.rb +56 -7
- data/lib/xero_kiwi/version.rb +1 -1
- data/lib/xero_kiwi.rb +3 -0
- data/llms-full.txt +637 -5
- metadata +21 -6
- data/docs/plans/0.3.0-querying.md +0 -309
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e67980bd7d7be1131ee07b55847cd4633a527eec4159103ba43551903f0870c
|
|
4
|
+
data.tar.gz: f06a30a5311f02be7d8695344a7f8eb65e76f4b0abab1743f23b9701f7655600
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf15925c5971526baeb6c3773de4f454ab917509200794b05957b27eabdfb4691e757a01f9defca50357cf0ce8ba7aa504b8e29973685c1428b365b28d80c5bf
|
|
7
|
+
data.tar.gz: 854a1cc894da5a8706e084f1b2e328a3529470f785e4cc22c27d80080ec7c2780cf3db40eb3f919fd9afd359539a879af82d241b6a2061f3673fa80ead876357
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.1] - 2026-09-26
|
|
4
|
+
|
|
5
|
+
### Documentation
|
|
6
|
+
|
|
7
|
+
- Pinned down the scope of `Resource#raw`, which was ambiguous enough to mislead a real consumer. It holds the resource's own item hash — `contact.raw` has no `"Contacts"` key — and it is the JSON representation specifically. Xero's XML representation nests differently (no arrays, so one child parses to a Hash and several to an Array), so `raw` cannot reproduce an XML-derived shape. `docs/client.md` gains a short migration section covering what that means for anyone replacing a client that sent `Accept: text/xml` and has stored payloads. Specs added for both the envelope scope and the PascalCase/snake_case split. The migration section sorts readers into the two piles that behave differently: ones that dig the XML-only structure and must change, and ones that already normalise (`[value].flatten.compact`) where only the writer changes and stored rows stay readable.
|
|
8
|
+
|
|
9
|
+
## [0.5.0] - 2026-09-26
|
|
10
|
+
|
|
11
|
+
The sync-support release: everything needed to drive a full-tenant sync through kiwi rather than around it.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`page_size:`** on every list method, plus a client-level default (`Client.new(page_size: 1_000)`) that per-call overrides beat. Maps to Xero's `pageSize`. Unset means the parameter is omitted and Xero applies its own default of 100, so existing callers are unaffected. Worth setting for any full sync — at 100 per page a 50,000-invoice tenant costs ~500 API calls against a 5,000/day limit; at 1,000 it costs ~50.
|
|
16
|
+
- **`each_<resource>_page`** for all ten list resources, yielding a whole `XeroKiwi::Page` rather than its items — so the page number reaches the caller, which is what a resumable sync needs to record.
|
|
17
|
+
- **`start_page:`** on both `each_<resource>` and `each_<resource>_page` (default 1), so a resumed run picks up where the last one stopped.
|
|
18
|
+
- **`include_archived:`** on `contacts` / `each_contact` / `each_contact_page`. A distinct query parameter, not a `where` filter: filtering on `contact_status` returns *only* archived contacts, where this returns both kinds in one pass.
|
|
19
|
+
- **`Resource#raw`** — Xero's untouched response hash, behind `Client.new(retain_raw: true)`. Off by default because retaining it roughly doubles the memory of a large page. Populated on resources built directly from a response, not on nested objects; the top-level hash already holds every nested payload, so `contact.raw["ContactPersons"]` gets there. Note `#raw` is not `#to_h` — `to_h` is a snake_case projection with different keys and different nesting.
|
|
20
|
+
- **`client.rate_limit(tenant_id)`** returning a `XeroKiwi::RateLimit`. Blends what Xero's headers last reported for that tenant with what the configured throttle bucket holds, and reports **the stricter of the two** — a configured limit is a ceiling you chose, a reported limit is reality. Exposes `day_remaining`, `minute_remaining`, `day_below?`, `minute_below?`, `day_source`, `minute_source`, `reported`, `configured` and `known?`. Headers are captured on every response including errors, since a 429 is when they matter most. With nothing known, `day_below?` returns false — not knowing isn't a reason to halt a sync.
|
|
21
|
+
- **`RedisTokenBucket#remaining(key)`** — current token counts without spending one, via a separate read-only Lua script. `#remaining` is an optional part of the limiter contract; `Client` checks `respond_to?` first, so a custom limiter written against 0.2.0 keeps working.
|
|
22
|
+
- **Tracking categories**: `client.tracking_categories`, `client.tracking_category`, `each_tracking_category` and `each_tracking_category_page`, with a new `Accounting::TrackingOption` for the nested `Options` array. See `docs/accounting/tracking-category.md`.
|
|
23
|
+
- `XeroKiwi::Page#reported_page_size` — the page size Xero actually stated, or nil when the response carried no `pagination` envelope. `page_size` keeps its existing fallback to the item count.
|
|
24
|
+
|
|
25
|
+
### Breaking
|
|
26
|
+
|
|
27
|
+
- **`Accounting::TrackingCategory` is now the `/TrackingCategories` endpoint resource** (`tracking_category_id`, `name`, `status`, `options`). The flattened category-and-chosen-option pair nested on line items and contacts — which this class used to model — is now **`Accounting::Tracking`**, matching Xero's own field name for it. Update any reference to `XeroKiwi::Accounting::TrackingCategory` that came from `line_item.tracking` or a contact's tracking collections. The two shapes share a name and one ID field and nothing else, which is why they're now separate classes.
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Page walks no longer fetch a redundant empty page. `build_page` falls back to the item count when Xero returns no `pagination` envelope, which made the walker's short-page check compare a number to itself (`items.size < items.size`) and never fire, leaving `empty?` as the only way to stop. The walk now measures against Xero's stated page size when there is one and the largest page seen so far otherwise — deliberately **not** against the requested `page_size`, since Xero clamps a request above an endpoint's maximum and a walk that asked for 2,000 where the cap is 1,000 would have seen page 1 as short and truncated the sync.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- Widened the `jwt` runtime constraint to `>= 2.7, < 4.0` and `redis` to `>= 5.0, < 7.0`. Both majors (`jwt` 3.x, `redis` 6.x) pass the full suite, including the Lua-backed throttle specs against a real Redis. Widening rather than bumping means a host app on `redis` 5 (Sidekiq, Rails cache) isn't forced to move in lockstep with this gem.
|
|
36
|
+
- Dropped the unused `mock_redis` development dependency. The throttle's bucket maths runs as a server-side Lua script, so the specs have always used a real Redis — `mock_redis` was only ever named in the comments explaining why it couldn't be used, and its `redis (~> 5)` runtime pin blocked resolving `redis` 6.
|
|
37
|
+
|
|
3
38
|
## [0.4.0] - 2026-04-20
|
|
4
39
|
|
|
5
40
|
### Added
|
data/README.md
CHANGED
|
@@ -23,9 +23,16 @@ problem.
|
|
|
23
23
|
themes, and nested objects like addresses, phones, external links, and payment
|
|
24
24
|
terms — all wrapped in proper value objects.
|
|
25
25
|
- **Querying**: every list endpoint accepts `where` / `order` / `page` /
|
|
26
|
-
`modified_since`, with a typed hash DSL
|
|
27
|
-
and raw-string escape hatches. Lazy
|
|
28
|
-
page for whole-tenant scans and
|
|
26
|
+
`page_size` / `modified_since`, with a typed hash DSL
|
|
27
|
+
(`where: { status: "AUTHORISED" }`) and raw-string escape hatches. Lazy
|
|
28
|
+
`each_<resource>` helpers walk every page for whole-tenant scans and
|
|
29
|
+
incremental syncs.
|
|
30
|
+
- **Resumable syncs**: `each_<resource>_page` yields whole pages so you can
|
|
31
|
+
record the page number alongside the rows, and `start_page:` picks the walk
|
|
32
|
+
back up where it stopped.
|
|
33
|
+
- **Quota introspection**: `client.rate_limit(tenant_id)` reports how much of
|
|
34
|
+
a tenant's daily and per-minute allowance is left, taking the stricter of
|
|
35
|
+
what Xero reported and what your own throttle bucket holds.
|
|
29
36
|
|
|
30
37
|
## Installation
|
|
31
38
|
|
|
@@ -71,12 +78,13 @@ below.
|
|
|
71
78
|
| [Overpayments](docs/accounting/overpayment.md) | Listing and fetching overpayments, the `XeroKiwi::Accounting::Overpayment` resource |
|
|
72
79
|
| [Prepayments](docs/accounting/prepayment.md) | Listing and fetching prepayments, the `XeroKiwi::Accounting::Prepayment` resource, LineItem |
|
|
73
80
|
| [Branding Themes](docs/accounting/branding-theme.md) | Listing and fetching branding themes, the `XeroKiwi::Accounting::BrandingTheme` resource |
|
|
81
|
+
| [Tracking Categories](docs/accounting/tracking-category.md) | Listing and fetching tracking categories, the `TrackingCategory` / `TrackingOption` / `Tracking` resources |
|
|
74
82
|
| [Tokens](docs/tokens.md) | The `XeroKiwi::Token` value object, automatic refresh, revocation, persistence callbacks |
|
|
75
83
|
| [OAuth](docs/oauth.md) | Authorization URL building, code exchange, PKCE, ID token verification, full Rails-style example |
|
|
76
84
|
| [Errors](docs/errors.md) | The error hierarchy, what to catch and when |
|
|
77
85
|
| [Retries and rate limits](docs/retries-and-rate-limits.md) | How Xero Kiwi handles 429s and transient failures, customising the retry policy |
|
|
78
86
|
| [Throttling](docs/throttling.md) | Redis-backed token bucket for proactive rate-limit coordination across multiple workers |
|
|
79
|
-
| [Querying](docs/querying.md) | `where` / `order` / `page` / `modified_since` on list endpoints, the `Page` return type,
|
|
87
|
+
| [Querying](docs/querying.md) | `where` / `order` / `page` / `page_size` / `include_archived` / `modified_since` on list endpoints, the `Page` return type, `each_*` and `each_*_page` helpers, and `start_page:` resumability |
|
|
80
88
|
|
|
81
89
|
## Status
|
|
82
90
|
|
data/Rakefile
CHANGED
|
@@ -28,12 +28,14 @@ LLMS_SOURCE_FILES = %w[
|
|
|
28
28
|
docs/accounting/overpayment.md
|
|
29
29
|
docs/accounting/prepayment.md
|
|
30
30
|
docs/accounting/branding-theme.md
|
|
31
|
+
docs/accounting/tracking-category.md
|
|
31
32
|
docs/accounting/address.md
|
|
32
33
|
docs/accounting/phone.md
|
|
33
34
|
docs/accounting/external-link.md
|
|
34
35
|
docs/accounting/payment-terms.md
|
|
35
36
|
docs/errors.md
|
|
36
37
|
docs/retries-and-rate-limits.md
|
|
38
|
+
docs/throttling.md
|
|
37
39
|
docs/querying.md
|
|
38
40
|
].freeze
|
|
39
41
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Tracking Categories
|
|
2
|
+
|
|
3
|
+
Xero **tracking categories** let an organisation tag transactions along its
|
|
4
|
+
own dimensions — Region, Department, Cost Centre. Each category holds a set
|
|
5
|
+
of options (Eastside, Westside), and a line item picks at most one option
|
|
6
|
+
per category. An organisation can have at most two active categories.
|
|
7
|
+
|
|
8
|
+
You need a `tenant_id` from a [connection](../connections.md) before you can
|
|
9
|
+
fetch tracking categories.
|
|
10
|
+
|
|
11
|
+
> See: [Xero docs — Tracking Categories](https://developer.xero.com/documentation/api/accounting/trackingcategories)
|
|
12
|
+
|
|
13
|
+
## Two shapes, two classes
|
|
14
|
+
|
|
15
|
+
Xero uses the phrase "tracking category" for two different things, and kiwi
|
|
16
|
+
models them as two separate classes. Getting these mixed up is the most
|
|
17
|
+
common confusion here, so it's worth being explicit.
|
|
18
|
+
|
|
19
|
+
**`XeroKiwi::Accounting::TrackingCategory`** — the *definition*, returned by
|
|
20
|
+
`GET /TrackingCategories`. The category plus every option available under it.
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{ "TrackingCategoryID": "abc", "Name": "Region", "Status": "ACTIVE",
|
|
24
|
+
"Options": [ { "TrackingOptionID": "def", "Name": "Eastside", "Status": "ACTIVE" } ] }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**`XeroKiwi::Accounting::Tracking`** — the *assignment*, nested on a line
|
|
28
|
+
item (Xero's own field name for it is `Tracking`) or a contact. One category
|
|
29
|
+
and the single option chosen for it, flattened.
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{ "TrackingCategoryID": "abc", "TrackingOptionID": "def",
|
|
33
|
+
"Name": "Region", "Option": "Eastside" }
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
They share a name and one ID field and nothing else. If you're reading
|
|
37
|
+
`invoice.line_items.first.tracking`, you have `Tracking` objects. If you
|
|
38
|
+
called `client.tracking_categories`, you have `TrackingCategory` objects.
|
|
39
|
+
|
|
40
|
+
## Listing tracking categories
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
client = XeroKiwi::Client.new(access_token: "ya29...")
|
|
44
|
+
|
|
45
|
+
categories = client.tracking_categories(tenant_id)
|
|
46
|
+
|
|
47
|
+
categories.first.name # => "Region"
|
|
48
|
+
categories.first.options.map(&:name) # => ["Eastside", "Westside"]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`client.tracking_categories` hits `GET /api.xro/2.0/TrackingCategories` with
|
|
52
|
+
the `Xero-Tenant-Id` header set, and returns a `XeroKiwi::Page` of
|
|
53
|
+
`TrackingCategory` objects.
|
|
54
|
+
|
|
55
|
+
This endpoint isn't paged — an organisation has at most two active
|
|
56
|
+
categories. `each_tracking_category` exists so all ten list resources behave
|
|
57
|
+
consistently, but there's no reason to reach for it over the plain call.
|
|
58
|
+
|
|
59
|
+
## Fetching a single category
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
category = client.tracking_category(tenant_id, "e2f2f732-e92a-4f3a-9c4d-ee4da0182a13")
|
|
63
|
+
category.name # => "Region"
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Hits `GET /api.xro/2.0/TrackingCategories/{TrackingCategoryID}` and returns a
|
|
67
|
+
single `TrackingCategory`, or `nil` if the response is empty.
|
|
68
|
+
|
|
69
|
+
## The TrackingCategory object
|
|
70
|
+
|
|
71
|
+
| Attribute | Type | What it is |
|
|
72
|
+
|-----------|------|------------|
|
|
73
|
+
| `tracking_category_id` | `String` | The unique Xero identifier for the category. |
|
|
74
|
+
| `name` | `String` | The category's display name (e.g. "Region"). |
|
|
75
|
+
| `status` | `String` | `"ACTIVE"` or `"ARCHIVED"`. |
|
|
76
|
+
| `options` | `Array<TrackingOption>` | The options available under this category. Empty array when absent. |
|
|
77
|
+
|
|
78
|
+
`#active?` is a shorthand for `status == "ACTIVE"`.
|
|
79
|
+
|
|
80
|
+
## The TrackingOption object
|
|
81
|
+
|
|
82
|
+
| Attribute | Type | What it is |
|
|
83
|
+
|-----------|------|------------|
|
|
84
|
+
| `tracking_option_id` | `String` | The unique Xero identifier for the option. |
|
|
85
|
+
| `name` | `String` | The option's display name (e.g. "Eastside"). |
|
|
86
|
+
| `status` | `String` | `"ACTIVE"` or `"DELETED"`. |
|
|
87
|
+
|
|
88
|
+
`#active?` is a shorthand for `status == "ACTIVE"`.
|
|
89
|
+
|
|
90
|
+
## The Tracking object
|
|
91
|
+
|
|
92
|
+
The assignment shape, nested on line items and contacts.
|
|
93
|
+
|
|
94
|
+
| Attribute | Type | What it is |
|
|
95
|
+
|-----------|------|------------|
|
|
96
|
+
| `tracking_category_id` | `String` | Which category this assignment is for. |
|
|
97
|
+
| `tracking_option_id` | `String` | Which option was chosen. |
|
|
98
|
+
| `name` | `String` | The category's name, denormalised by Xero. |
|
|
99
|
+
| `option` | `String` | The chosen option's name, denormalised by Xero. |
|
|
100
|
+
|
|
101
|
+
## Querying
|
|
102
|
+
|
|
103
|
+
`name` and `status` are queryable on both `TrackingCategory` and
|
|
104
|
+
`TrackingOption`, as is the identity field.
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
client.tracking_categories(tenant_id, where: { status: "ACTIVE" })
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Equality and hashing
|
|
111
|
+
|
|
112
|
+
Two `TrackingCategory` objects are `==` if they share the same
|
|
113
|
+
`tracking_category_id`; two `TrackingOption` objects if they share the same
|
|
114
|
+
`tracking_option_id`. `Tracking` has no server-side primary key of its own,
|
|
115
|
+
so it falls back to structural equality — every attribute must match.
|
|
116
|
+
|
|
117
|
+
## Error behaviour
|
|
118
|
+
|
|
119
|
+
| HTTP status | Exception | What it usually means |
|
|
120
|
+
|-------------|-----------|------------------------|
|
|
121
|
+
| 200 | (none — returns categories) | Success |
|
|
122
|
+
| 401 | `XeroKiwi::AuthenticationError` | Access token is invalid or expired |
|
|
123
|
+
| 403 | `XeroKiwi::ClientError` | The token doesn't have the `accounting.settings` scope |
|
|
124
|
+
| 404 | `XeroKiwi::ClientError` | The tracking category ID doesn't exist in this organisation |
|
|
125
|
+
|
|
126
|
+
## Common patterns
|
|
127
|
+
|
|
128
|
+
### Building a lookup from option ID to names
|
|
129
|
+
|
|
130
|
+
Line items carry only IDs and denormalised names; if you need the canonical
|
|
131
|
+
option list, fetch the definitions once per sync and index them.
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
options = client.tracking_categories(tenant_id).flat_map do |category|
|
|
135
|
+
category.options.map { |option| [option.tracking_option_id, [category.name, option.name]] }
|
|
136
|
+
end.to_h
|
|
137
|
+
|
|
138
|
+
invoice.line_items.each do |line|
|
|
139
|
+
line.tracking.each do |assignment|
|
|
140
|
+
category_name, option_name = options.fetch(assignment.tracking_option_id)
|
|
141
|
+
puts "#{category_name}: #{option_name}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Ignoring archived categories
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
active = client.tracking_categories(tenant_id).select(&:active?)
|
|
150
|
+
```
|
data/docs/client.md
CHANGED
|
@@ -41,6 +41,98 @@ you. See [Tokens](tokens.md) for the full refresh story.
|
|
|
41
41
|
| `adapter:` | `Symbol` / Faraday adapter | No | `Faraday.default_adapter` | The Faraday adapter to use. Override to swap in `:net_http_persistent`, `:typhoeus`, or a test adapter. |
|
|
42
42
|
| `user_agent:` | `String` | No | `"XeroKiwi/<version>"` | Sent as the `User-Agent` header on every request. |
|
|
43
43
|
| `retry_options:` | `Hash` | No | See [retries and rate limits](retries-and-rate-limits.md) | Overrides for the `faraday-retry` configuration. Merged into the defaults. |
|
|
44
|
+
| `throttle:` | Limiter | No | `XeroKiwi.default_throttle`, else none | Proactive per-tenant rate limiting. See [Throttling](throttling.md). |
|
|
45
|
+
| `page_size:` | `Integer` | No | `nil` (Xero's own default of 100) | Default `pageSize` for every list call. Per-call `page_size:` overrides it. See [Querying](querying.md). |
|
|
46
|
+
| `retain_raw:` | `Boolean` | No | `false` | Keep Xero's untouched response hash on each resource, readable via `#raw`. See below. |
|
|
47
|
+
|
|
48
|
+
### `retain_raw:` and `#raw`
|
|
49
|
+
|
|
50
|
+
By default a resource hydrates the fields kiwi models and discards the
|
|
51
|
+
original payload. Turn `retain_raw` on and each resource built directly
|
|
52
|
+
from a response keeps that payload:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
client = XeroKiwi::Client.new(access_token: token, retain_raw: true)
|
|
56
|
+
contact = client.contact(tenant, contact_id)
|
|
57
|
+
|
|
58
|
+
contact.raw
|
|
59
|
+
# => {"ContactID" => "…", "ContactPersons" => [...], "SomeNewField" => "…"}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Use it to reach fields kiwi doesn't model yet.
|
|
63
|
+
|
|
64
|
+
Five things to know:
|
|
65
|
+
|
|
66
|
+
- **It's the item, not the envelope.** `contact.raw` has no `"Contacts"`
|
|
67
|
+
key and `organisation.raw` has no `"Organisations"` key — kiwi unwraps
|
|
68
|
+
the envelope before building a resource, so there's nothing left of it by
|
|
69
|
+
the time `raw` is populated. If you need the envelope back, you're
|
|
70
|
+
rebuilding it yourself.
|
|
71
|
+
- **It's the JSON representation.** Kiwi sends
|
|
72
|
+
`Accept: application/json`. Xero also serves XML, which nests
|
|
73
|
+
differently — XML has no arrays, so a single child parses to a Hash and
|
|
74
|
+
several to an Array, where JSON is always an Array. `raw` cannot
|
|
75
|
+
reproduce an XML-derived shape. See [migrating from an XML
|
|
76
|
+
client](#migrating-from-an-xml-based-client) below.
|
|
77
|
+
- **`#raw` is not `#to_h`.** `to_h` is a snake_case projection rebuilt from
|
|
78
|
+
the modelled attributes — different keys, different nesting. If you store
|
|
79
|
+
`to_h` where you meant to store the payload, readers fail by returning
|
|
80
|
+
nil rather than raising.
|
|
81
|
+
- **It's per resource, not per nested object.** Line items, addresses and
|
|
82
|
+
contact persons have no `#raw` of their own. They don't need one: the
|
|
83
|
+
resource's hash already holds every nested payload, so
|
|
84
|
+
`contact.raw["ContactPersons"]` gets there.
|
|
85
|
+
- **It costs memory.** Every resource holds its source hash alongside the
|
|
86
|
+
hydrated attributes, which roughly doubles the footprint of a large page.
|
|
87
|
+
That's why it's off by default.
|
|
88
|
+
|
|
89
|
+
### Migrating from an XML-based client
|
|
90
|
+
|
|
91
|
+
If you're replacing a Xero client that sent `Accept: text/xml` — HTTParty
|
|
92
|
+
and similar default to it — any payloads you already have stored are
|
|
93
|
+
XML-shaped, and `raw` will not match them. The differences are structural,
|
|
94
|
+
not cosmetic:
|
|
95
|
+
|
|
96
|
+
| | XML (`text/xml`) | JSON (`application/json`) |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| Organisation body | `{"Organisations" => {"Organisation" => {…}}}` | `{"Organisations" => [{…}]}` |
|
|
99
|
+
| One address | `{"Address" => {…}}` | `[{…}]` |
|
|
100
|
+
| Several addresses | `{"Address" => [{…}, {…}]}` | `[{…}, {…}]` |
|
|
101
|
+
|
|
102
|
+
The single-child collapse is the one that catches people: under XML a
|
|
103
|
+
contact with one person parses to a Hash and a contact with two parses to
|
|
104
|
+
an Array, from the same endpoint. Code written against that has a
|
|
105
|
+
normalising step somewhere, whether or not its author knew why.
|
|
106
|
+
|
|
107
|
+
No client setting reproduces these shapes — they're artefacts of an XML
|
|
108
|
+
parse kiwi doesn't do. But the remedy isn't the same everywhere, and it's
|
|
109
|
+
worth sorting your readers into two piles before planning the work.
|
|
110
|
+
|
|
111
|
+
**Readers that dig the XML-only structure have to change.** Something like
|
|
112
|
+
`dig("Organisations", "Organisation", "Addresses", "Address")` returns nil
|
|
113
|
+
against anything kiwi produces, whether you store `raw` or `to_h`.
|
|
114
|
+
Promote the fields those readers need to real columns and use `raw` for
|
|
115
|
+
the backfill — it's the true payload, so it carries everything required to
|
|
116
|
+
populate them.
|
|
117
|
+
|
|
118
|
+
**Readers that already normalise usually survive untouched.** A reader
|
|
119
|
+
doing `[value].flatten.compact` handles the Hash case, the Array case and
|
|
120
|
+
nil identically, so a JSON array flows straight through. If the keys it
|
|
121
|
+
reads are the same in both representations, only the *writer* changes:
|
|
122
|
+
stop unwrapping, store the array. Existing rows stay readable, and you
|
|
123
|
+
skip a migration entirely.
|
|
124
|
+
|
|
125
|
+
Type coercion is usually fine in that second pile too. XML gives you
|
|
126
|
+
strings — `"false"` rather than `false` — and if the reader hands that to
|
|
127
|
+
something like ActiveModel's boolean cast, both the old string and the new
|
|
128
|
+
real boolean land on the same value.
|
|
129
|
+
|
|
130
|
+
One piece of luck worth knowing about: the two piles tend to fail
|
|
131
|
+
differently. A `dig` that misses returns nil and writes a blank record
|
|
132
|
+
quietly. Code that assumed a Hash, such as `Array#to_h` on what is now a
|
|
133
|
+
list, raises `TypeError` on the first record with data in it. The noisy
|
|
134
|
+
failures are the ones you can trust to find themselves — budget your
|
|
135
|
+
review time for the silent ones.
|
|
44
136
|
|
|
45
137
|
## What the client gives you
|
|
46
138
|
|
data/docs/querying.md
CHANGED
|
@@ -130,6 +130,28 @@ client.invoices(tenant, page: 2).page_size # => 100
|
|
|
130
130
|
client.invoices(tenant, page: 2).item_count # total-on-this-page
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
+
## `page_size:` — how many per page
|
|
134
|
+
|
|
135
|
+
Maps to Xero's `pageSize` query param. Xero's own default is 100 and its
|
|
136
|
+
maximum is 1,000 on most paged endpoints.
|
|
137
|
+
|
|
138
|
+
Set it once on the client and every call inherits it; override per call
|
|
139
|
+
where you need something different.
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
client = XeroKiwi::Client.new(access_token: token, page_size: 1_000)
|
|
143
|
+
|
|
144
|
+
client.invoices(tenant) # pageSize=1000
|
|
145
|
+
client.invoices(tenant, page_size: 100) # this call only
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Leave it unset and kiwi omits the parameter entirely, so Xero applies its
|
|
149
|
+
own default.
|
|
150
|
+
|
|
151
|
+
This is worth setting for any full-tenant sync. At 100 per page a
|
|
152
|
+
50,000-invoice tenant costs ~500 API calls; at 1,000 it costs ~50, against
|
|
153
|
+
a daily limit of 5,000.
|
|
154
|
+
|
|
133
155
|
### Walking every page — `each_<resource>`
|
|
134
156
|
|
|
135
157
|
For incremental syncs or whole-tenant scans, use the `each_*` helpers.
|
|
@@ -150,7 +172,50 @@ client.each_invoice(tenant, order: { date: :desc })
|
|
|
150
172
|
|
|
151
173
|
Available for every listable resource: `each_user`, `each_contact`,
|
|
152
174
|
`each_contact_group`, `each_invoice`, `each_credit_note`, `each_payment`,
|
|
153
|
-
`each_prepayment`, `each_overpayment`, `each_branding_theme
|
|
175
|
+
`each_prepayment`, `each_overpayment`, `each_branding_theme`,
|
|
176
|
+
`each_tracking_category`.
|
|
177
|
+
|
|
178
|
+
### Walking pages instead of items — `each_<resource>_page`
|
|
179
|
+
|
|
180
|
+
Same walk, but each yield is a whole `Page` rather than one item. Use it
|
|
181
|
+
when you need the page number — which is what makes a sync resumable.
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
client.each_invoice_page(tenant, page_size: 1_000) do |page|
|
|
185
|
+
Invoice.upsert_all(page.map(&:to_h))
|
|
186
|
+
cursor.update!(invoices: page.page) # same transaction as the upsert
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Recording the page number in the same transaction that stores the rows
|
|
191
|
+
matters: a marker written when the response lands, before the rows are
|
|
192
|
+
saved, can survive a crash that the rows don't — and the next run then
|
|
193
|
+
skips a page it never actually imported.
|
|
194
|
+
|
|
195
|
+
### Resuming — `start_page:`
|
|
196
|
+
|
|
197
|
+
Both `each_*` and `each_*_page` accept `start_page:` (default 1), so a
|
|
198
|
+
resumed run picks up where the last one stopped.
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
client.each_invoice_page(tenant, start_page: cursor.invoices + 1) do |page|
|
|
202
|
+
…
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### How the walk knows when to stop
|
|
207
|
+
|
|
208
|
+
It stops on an empty page, or on a page shorter than a full one. "Full" is
|
|
209
|
+
measured against Xero's stated page size when the response carries a
|
|
210
|
+
`pagination` envelope, and otherwise against the largest page seen so far
|
|
211
|
+
in that walk.
|
|
212
|
+
|
|
213
|
+
It is deliberately **not** measured against the `page_size:` you asked
|
|
214
|
+
for. Xero clamps a request above an endpoint's maximum, so a walk that
|
|
215
|
+
asked for 2,000 where the cap is 1,000 would see its very first page as
|
|
216
|
+
short and stop after one page — silently truncating the sync. The cost of
|
|
217
|
+
measuring instead of assuming is one extra request when the whole result
|
|
218
|
+
fits in a single page and no envelope came back.
|
|
154
219
|
|
|
155
220
|
## `modified_since:` — incremental sync
|
|
156
221
|
|
|
@@ -168,6 +233,25 @@ exception, no special flag. An empty page after `modified_since:` is
|
|
|
168
233
|
indistinguishable from a filter that matched nothing (intentional — the
|
|
169
234
|
caller can treat them identically).
|
|
170
235
|
|
|
236
|
+
## `include_archived:` — archived contacts
|
|
237
|
+
|
|
238
|
+
Contacts only. Maps to Xero's `includeArchived` query param, which returns
|
|
239
|
+
archived contacts **alongside** active ones in the same pass.
|
|
240
|
+
|
|
241
|
+
```ruby
|
|
242
|
+
client.contacts(tenant, include_archived: true)
|
|
243
|
+
client.each_contact(tenant, include_archived: true) { |contact| … }
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
This is not the same as filtering on `contact_status`. A
|
|
247
|
+
`where: { contact_status: "ARCHIVED" }` returns *only* archived contacts;
|
|
248
|
+
`include_archived: true` returns both kinds together, which is what you
|
|
249
|
+
want when mirroring a tenant's full contact list.
|
|
250
|
+
|
|
251
|
+
It matters beyond contacts themselves: Xero keeps serving archived
|
|
252
|
+
contacts as members of contact groups, so without this you can't tell
|
|
253
|
+
which group members are archived locally.
|
|
254
|
+
|
|
171
255
|
## Combining everything
|
|
172
256
|
|
|
173
257
|
Mix and match freely:
|
|
@@ -178,6 +262,7 @@ client.invoices(
|
|
|
178
262
|
where: { status: "AUTHORISED", contact: { contact_id: "abc-123" } },
|
|
179
263
|
order: { date: :desc },
|
|
180
264
|
page: 1,
|
|
265
|
+
page_size: 1_000,
|
|
181
266
|
modified_since: last_sync_at
|
|
182
267
|
)
|
|
183
268
|
```
|
|
@@ -20,6 +20,54 @@ Xero enforces three separate rate limits, all returned with HTTP 429:
|
|
|
20
20
|
Plus a `Retry-After` header on every 429 telling you how many seconds to
|
|
21
21
|
wait before trying again.
|
|
22
22
|
|
|
23
|
+
## Checking how much quota is left
|
|
24
|
+
|
|
25
|
+
`client.rate_limit(tenant_id)` answers "how many calls can I still make
|
|
26
|
+
for this tenant today?" — useful when a long job should stop early and
|
|
27
|
+
leave headroom for everything else on the same tenant.
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
invoice_ids.each do |id|
|
|
31
|
+
break if client.rate_limit(tenant).day_below?(1_000)
|
|
32
|
+
|
|
33
|
+
urls << client.online_invoice_url(tenant, id)
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
It blends the only two sources that know anything, and always reports
|
|
38
|
+
**the stricter of the two**:
|
|
39
|
+
|
|
40
|
+
| Source | Where it comes from | What it sees |
|
|
41
|
+
|--------|--------------------|--------------|
|
|
42
|
+
| `reported` | Xero's `X-DayLimit-Remaining` / `X-MinLimit-Remaining` headers on the last call to that tenant | Every consumer of the tenant's quota, including other applications. Nil until a request has been made. |
|
|
43
|
+
| `configured` | The throttle limiter's own bucket | Only calls made through this limiter — but it encodes the headroom you deliberately configured. Nil when throttling is off. |
|
|
44
|
+
|
|
45
|
+
So if Xero reports 3,000 calls left but a bucket configured at 4,900/day
|
|
46
|
+
holds 500 tokens, the answer is 500 — the configured limit is a ceiling
|
|
47
|
+
you chose and the sync should not spend past it. If another app has burned
|
|
48
|
+
the tenant's quota so Xero reports 50 while the bucket still shows 2,000,
|
|
49
|
+
the answer is 50 — that one is reality.
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
rl = client.rate_limit(tenant)
|
|
53
|
+
|
|
54
|
+
rl.day_remaining # => 500 the binding number
|
|
55
|
+
rl.minute_remaining # => 55
|
|
56
|
+
rl.day_below?(1_000) # => true
|
|
57
|
+
rl.day_source # => :configured which one is binding
|
|
58
|
+
rl.reported # => #<struct day=3000, minute=58, app_minute=9800>
|
|
59
|
+
rl.configured # => #<struct day=500, minute=55>
|
|
60
|
+
rl.known? # => true
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The headers are captured on every response, including error responses — a
|
|
64
|
+
429 is exactly when they matter most.
|
|
65
|
+
|
|
66
|
+
When neither source has a reading, `day_remaining` is nil and
|
|
67
|
+
`day_below?` returns **false**. Not knowing how much quota is left isn't a
|
|
68
|
+
reason to halt a sync, and halting would break the common case where the
|
|
69
|
+
first call is what populates the figures.
|
|
70
|
+
|
|
23
71
|
## What Xero Kiwi does automatically
|
|
24
72
|
|
|
25
73
|
Xero Kiwi sets up a `faraday-retry` middleware that handles transient failures
|
data/docs/throttling.md
CHANGED
|
@@ -192,9 +192,28 @@ Fail-open is deliberate: a misbehaving Redis shouldn't stop your app talking
|
|
|
192
192
|
to Xero. The reactive retry layer still protects you from actually hitting
|
|
193
193
|
the limits.
|
|
194
194
|
|
|
195
|
+
## Asking how much is left
|
|
196
|
+
|
|
197
|
+
`RedisTokenBucket#remaining(tenant_id)` reports the current token counts
|
|
198
|
+
without spending one:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
bucket.remaining("tenant-abc")
|
|
202
|
+
# => { minute: 55, day: 4_312 }
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
It runs the same refill arithmetic as `acquire` in a separate read-only
|
|
206
|
+
Lua script, so polling it can't starve the bucket you're polling. Like
|
|
207
|
+
`acquire`, it fails open and returns `nil` if Redis is unreachable, and
|
|
208
|
+
`day` is `nil` when no `per_day` limit is configured.
|
|
209
|
+
|
|
210
|
+
Most callers won't use this directly — `client.rate_limit(tenant_id)`
|
|
211
|
+
combines it with what Xero itself reported and hands back whichever is
|
|
212
|
+
stricter. See [retries and rate limits](retries-and-rate-limits.md).
|
|
213
|
+
|
|
195
214
|
## Writing a custom limiter
|
|
196
215
|
|
|
197
|
-
The limiter contract is one method:
|
|
216
|
+
The limiter contract is one required method:
|
|
198
217
|
|
|
199
218
|
```ruby
|
|
200
219
|
class MyLimiter
|
|
@@ -203,6 +222,13 @@ class MyLimiter
|
|
|
203
222
|
# XeroKiwi::Throttle::Timeout / DailyLimitExhausted if you want the
|
|
204
223
|
# same exception shapes.
|
|
205
224
|
end
|
|
225
|
+
|
|
226
|
+
# Optional. Implement it and client.rate_limit(tenant_id) will factor
|
|
227
|
+
# your bucket into its answer; leave it out and kiwi falls back to
|
|
228
|
+
# Xero's reported headers alone.
|
|
229
|
+
def remaining(tenant_id)
|
|
230
|
+
{ minute: …, day: … }
|
|
231
|
+
end
|
|
206
232
|
end
|
|
207
233
|
```
|
|
208
234
|
|
|
@@ -11,7 +11,7 @@ module XeroKiwi
|
|
|
11
11
|
payload_key "Contacts"
|
|
12
12
|
identity :contact_id
|
|
13
13
|
|
|
14
|
-
# Some classes referenced below (
|
|
14
|
+
# Some classes referenced below (Tracking, ContactGroup,
|
|
15
15
|
# BrandingTheme) haven't been loaded at the time Contact's class body
|
|
16
16
|
# runs, so the lookup is deferred with a String reference.
|
|
17
17
|
|
|
@@ -40,8 +40,8 @@ module XeroKiwi
|
|
|
40
40
|
attribute :merged_to_contact_id, xero: "MergedToContactID", type: :guid
|
|
41
41
|
attribute :sales_default_account_code, xero: "SalesDefaultAccountCode"
|
|
42
42
|
attribute :purchases_default_account_code, xero: "PurchasesDefaultAccountCode"
|
|
43
|
-
attribute :sales_tracking_categories, xero: "SalesTrackingCategories", type: :collection, of: "
|
|
44
|
-
attribute :purchases_tracking_categories, xero: "PurchasesTrackingCategories", type: :collection, of: "
|
|
43
|
+
attribute :sales_tracking_categories, xero: "SalesTrackingCategories", type: :collection, of: "Tracking"
|
|
44
|
+
attribute :purchases_tracking_categories, xero: "PurchasesTrackingCategories", type: :collection, of: "Tracking"
|
|
45
45
|
attribute :sales_default_line_amount_type, xero: "SalesDefaultLineAmountType"
|
|
46
46
|
attribute :purchases_default_line_amount_type, xero: "PurchasesDefaultLineAmountType"
|
|
47
47
|
attribute :tracking_category_name, xero: "TrackingCategoryName"
|
|
@@ -21,7 +21,7 @@ module XeroKiwi
|
|
|
21
21
|
attribute :line_amount, xero: "LineAmount", type: :decimal
|
|
22
22
|
attribute :discount_rate, xero: "DiscountRate", type: :decimal
|
|
23
23
|
attribute :discount_amount, xero: "DiscountAmount", type: :decimal
|
|
24
|
-
attribute :tracking, xero: "Tracking", type: :collection, of:
|
|
24
|
+
attribute :tracking, xero: "Tracking", type: :collection, of: Tracking
|
|
25
25
|
attribute :item, xero: "Item"
|
|
26
26
|
end
|
|
27
27
|
end
|
|
@@ -45,14 +45,15 @@ module XeroKiwi
|
|
|
45
45
|
attribute :payment_terms, xero: "PaymentTerms", hydrate: ->(raw) { PaymentTerms.from_hash(raw) }
|
|
46
46
|
|
|
47
47
|
# Xero's /Organisation endpoint returns a one-element "Organisations"
|
|
48
|
-
# array — we unwrap it to a single object.
|
|
49
|
-
|
|
48
|
+
# array — we unwrap it to a single object. `opts` stays positional to
|
|
49
|
+
# match Resource.from_response; see the note there.
|
|
50
|
+
def self.from_response(payload, opts = {})
|
|
50
51
|
return nil if payload.nil?
|
|
51
52
|
|
|
52
53
|
items = payload["Organisations"]
|
|
53
54
|
return nil if items.nil? || items.empty?
|
|
54
55
|
|
|
55
|
-
new(items.first)
|
|
56
|
+
new(items.first, retain_raw: opts[:retain_raw])
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
def demo_company? = is_demo_company == true
|