xero-kiwi 0.4.0 → 0.5.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 +29 -0
- data/README.md +12 -4
- data/Rakefile +2 -0
- data/docs/accounting/tracking-category.md +150 -0
- data/docs/client.md +34 -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 +22 -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 +579 -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: b980ba3e8f03590d2ca887887d8509268bf87df52b882eef4730a9ce1512250c
|
|
4
|
+
data.tar.gz: bdd4952e2b7d1fd3af201f2446d17d5cd7f2bfe86e58ee9002f4115cc5624240
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e7086fef7948e63b5d9437a164a241f819e5a96beead2eb2701989613e8b95f643d805be209a63b98ab3dcd278c7e81f46165de054f1fd865318abb82a13e0e9
|
|
7
|
+
data.tar.gz: 94c08345afefa1b130634ab9d643052d14a24ba5200ae60a7b324344706d71465e9cecb6ce30e183876dafdf7bde0fcdbbaea487869ea99073bb388f2a7f8ab2
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2026-09-26
|
|
4
|
+
|
|
5
|
+
The sync-support release: everything needed to drive a full-tenant sync through kiwi rather than around it.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`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.
|
|
10
|
+
- **`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.
|
|
11
|
+
- **`start_page:`** on both `each_<resource>` and `each_<resource>_page` (default 1), so a resumed run picks up where the last one stopped.
|
|
12
|
+
- **`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.
|
|
13
|
+
- **`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.
|
|
14
|
+
- **`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.
|
|
15
|
+
- **`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.
|
|
16
|
+
- **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`.
|
|
17
|
+
- `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.
|
|
18
|
+
|
|
19
|
+
### Breaking
|
|
20
|
+
|
|
21
|
+
- **`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.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- 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.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- 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.
|
|
30
|
+
- 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.
|
|
31
|
+
|
|
3
32
|
## [0.4.0] - 2026-04-20
|
|
4
33
|
|
|
5
34
|
### 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,40 @@ 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, or to store what Xero sent
|
|
63
|
+
verbatim.
|
|
64
|
+
|
|
65
|
+
Three things to know:
|
|
66
|
+
|
|
67
|
+
- **`#raw` is not `#to_h`.** `to_h` is a snake_case projection rebuilt from
|
|
68
|
+
the modelled attributes — different keys, different nesting. If you store
|
|
69
|
+
`to_h` where you meant to store the payload, readers fail by returning
|
|
70
|
+
nil rather than raising.
|
|
71
|
+
- **It's top-level only.** Nested objects (line items, addresses, contact
|
|
72
|
+
persons) have no `#raw` of their own. They don't need one: the top-level
|
|
73
|
+
hash already holds every nested payload, so `contact.raw["ContactPersons"]`
|
|
74
|
+
gets there.
|
|
75
|
+
- **It costs memory.** Every resource holds its source hash alongside the
|
|
76
|
+
hydrated attributes, which roughly doubles the footprint of a large page.
|
|
77
|
+
That's why it's off by default.
|
|
44
78
|
|
|
45
79
|
## What the client gives you
|
|
46
80
|
|
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
|
|
@@ -99,13 +99,24 @@ module XeroKiwi
|
|
|
99
99
|
|
|
100
100
|
public
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
# `opts[:retain_raw]` is threaded down from Client#initialize. It
|
|
103
|
+
# applies to the resources built here and not to their nested
|
|
104
|
+
# objects — the top-level `raw` hash already holds every nested
|
|
105
|
+
# payload verbatim, so `contact.raw["ContactPersons"]` gets there
|
|
106
|
+
# without pushing the flag through Hydrator and every build_object
|
|
107
|
+
# call.
|
|
108
|
+
#
|
|
109
|
+
# `opts` is positional for the same reason it is on #initialize: a
|
|
110
|
+
# bare string-keyed payload (`from_response("Users" => [])`) would
|
|
111
|
+
# otherwise be swallowed as keyword arguments in Ruby 3, leaving
|
|
112
|
+
# `payload` unset.
|
|
113
|
+
def from_response(payload, opts = {})
|
|
103
114
|
return [] if payload.nil?
|
|
104
115
|
|
|
105
116
|
items = payload[payload_key]
|
|
106
117
|
return [] if items.nil?
|
|
107
118
|
|
|
108
|
-
items.map { |attrs| new(attrs) }
|
|
119
|
+
items.map { |attrs| new(attrs, retain_raw: opts[:retain_raw]) }
|
|
109
120
|
end
|
|
110
121
|
end
|
|
111
122
|
|
|
@@ -116,6 +127,7 @@ module XeroKiwi
|
|
|
116
127
|
def initialize(attrs, opts = {})
|
|
117
128
|
attrs = attrs.transform_keys(&:to_s)
|
|
118
129
|
@is_reference = opts[:reference] == true
|
|
130
|
+
@raw = opts[:retain_raw] ? attrs.freeze : nil
|
|
119
131
|
|
|
120
132
|
self.class.attributes.each do |name, spec|
|
|
121
133
|
value = Hydrator.call(attrs[spec[:xero]], spec)
|
|
@@ -123,6 +135,14 @@ module XeroKiwi
|
|
|
123
135
|
end
|
|
124
136
|
end
|
|
125
137
|
|
|
138
|
+
# Xero's response hash for this resource, exactly as it arrived, or nil
|
|
139
|
+
# unless the client was built with `retain_raw: true`. Use it to reach
|
|
140
|
+
# fields the gem doesn't model, or to store the payload verbatim.
|
|
141
|
+
#
|
|
142
|
+
# Note `to_h` is NOT this — it's a snake_case projection rebuilt from
|
|
143
|
+
# the modelled attributes, with different keys and different nesting.
|
|
144
|
+
attr_reader :raw
|
|
145
|
+
|
|
126
146
|
def reference?
|
|
127
147
|
@is_reference
|
|
128
148
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module XeroKiwi
|
|
4
|
+
module Accounting
|
|
5
|
+
# A tracking assignment — one category and the single option chosen for
|
|
6
|
+
# it — as nested on a line item (`Tracking`) or a contact. This is not
|
|
7
|
+
# the same shape as the /TrackingCategories endpoint returns; see
|
|
8
|
+
# Accounting::TrackingCategory for that.
|
|
9
|
+
#
|
|
10
|
+
# See: https://developer.xero.com/documentation/api/accounting/invoices
|
|
11
|
+
class Tracking
|
|
12
|
+
include Resource
|
|
13
|
+
|
|
14
|
+
attribute :tracking_category_id, xero: "TrackingCategoryID", type: :guid
|
|
15
|
+
attribute :tracking_option_id, xero: "TrackingOptionID", type: :guid
|
|
16
|
+
attribute :name, xero: "Name"
|
|
17
|
+
attribute :option, xero: "Option"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -2,16 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
module XeroKiwi
|
|
4
4
|
module Accounting
|
|
5
|
-
#
|
|
5
|
+
# A tracking category *definition* as returned by /TrackingCategories —
|
|
6
|
+
# the category itself plus the options available under it.
|
|
6
7
|
#
|
|
7
|
-
#
|
|
8
|
+
# Not to be confused with Accounting::Tracking, which is the flattened
|
|
9
|
+
# category-and-chosen-option pair nested on line items and contacts.
|
|
10
|
+
#
|
|
11
|
+
# See: https://developer.xero.com/documentation/api/accounting/trackingcategories
|
|
8
12
|
class TrackingCategory
|
|
9
13
|
include Resource
|
|
10
14
|
|
|
15
|
+
payload_key "TrackingCategories"
|
|
16
|
+
identity :tracking_category_id
|
|
17
|
+
|
|
11
18
|
attribute :tracking_category_id, xero: "TrackingCategoryID", type: :guid
|
|
12
|
-
attribute :
|
|
13
|
-
attribute :
|
|
14
|
-
attribute :
|
|
19
|
+
attribute :name, xero: "Name", query: true
|
|
20
|
+
attribute :status, xero: "Status", type: :enum, query: true
|
|
21
|
+
attribute :options, xero: "Options", type: :collection, of: TrackingOption
|
|
22
|
+
|
|
23
|
+
def active? = status == "ACTIVE"
|
|
15
24
|
end
|
|
16
25
|
end
|
|
17
26
|
end
|