xero-kiwi 0.2.1 → 0.3.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 +17 -0
- data/README.md +5 -0
- data/Rakefile +3 -2
- data/docs/plans/0.3.0-querying.md +309 -0
- data/docs/querying.md +199 -0
- data/lib/xero_kiwi/accounting/branding_theme.rb +1 -1
- data/lib/xero_kiwi/accounting/contact.rb +9 -9
- data/lib/xero_kiwi/accounting/contact_group.rb +2 -2
- data/lib/xero_kiwi/accounting/credit_note.rb +5 -5
- data/lib/xero_kiwi/accounting/invoice.rb +8 -8
- data/lib/xero_kiwi/accounting/overpayment.rb +5 -5
- data/lib/xero_kiwi/accounting/payment.rb +6 -6
- data/lib/xero_kiwi/accounting/prepayment.rb +5 -5
- data/lib/xero_kiwi/accounting/resource.rb +42 -5
- data/lib/xero_kiwi/accounting/user.rb +6 -6
- data/lib/xero_kiwi/client.rb +199 -82
- data/lib/xero_kiwi/page.rb +49 -0
- data/lib/xero_kiwi/query/filter.rb +93 -0
- data/lib/xero_kiwi/query/order.rb +35 -0
- data/lib/xero_kiwi/query.rb +13 -0
- data/lib/xero_kiwi/version.rb +1 -1
- data/lib/xero_kiwi.rb +2 -0
- data/llms-full.txt +210 -0
- metadata +7 -2
- data/docs/plans/attribute-dsl-refactor.md +0 -301
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec24e1de644ead6640027a7d62ac1df0eda8677866e2f76f9c4e65c6a36d3750
|
|
4
|
+
data.tar.gz: a35d014164892fe7aefda15f4c9549d8da82fc292c85f7fcd2e6edb8bdc27458
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ab8caf90f556f23e2cd8d324f2995d7c6a32f18f90728463d3b561199ac5d00181b75de84594e96f9526fa1d32c97d6f97038d48f84a2f49dc41c82d87d68338
|
|
7
|
+
data.tar.gz: '048baedacba51f2cb1aed61e2fb5539cfcb90af075d64f149a305015cf4feb78fc7dd1adee2aec0af178aaf146d732ec28e341228f4dc89f40e38ff8c3be1c22'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-04-20
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Every accounting list endpoint (`contacts`, `invoices`, `credit_notes`, `overpayments`, `prepayments`, `payments`, `users`, `branding_themes`, `contact_groups`) now accepts `where:`, `order:`, `page:`, and `modified_since:` kwargs. `where:` and `order:` take a typed Hash (field-name-aware, safe literal formatting) or a raw String (escape hatch). `page:` maps to Xero's `page` query param. `modified_since: Time` sends `If-Modified-Since`; a `304 Not Modified` response returns an empty `Page`. See `docs/querying.md`.
|
|
8
|
+
- New `XeroKiwi::Page` return type for every list method — `Enumerable` with `size` / `empty?` / `to_a` / `page` / `page_size` / `item_count` / `total_count`.
|
|
9
|
+
- Lazy `each_<resource>` helpers (`each_invoice`, `each_contact`, `each_payment`, `each_credit_note`, `each_prepayment`, `each_overpayment`, `each_branding_theme`, `each_contact_group`, `each_user`) that walk every page for whole-tenant scans or incremental syncs. Returns an `Enumerator` when no block is given.
|
|
10
|
+
- `attribute` DSL gains a `query: true` kwarg; `identity` attributes are auto-included in the resource's `query_fields` schema so you rarely need `query: true` on IDs.
|
|
11
|
+
|
|
12
|
+
### Breaking
|
|
13
|
+
|
|
14
|
+
- List methods now return `XeroKiwi::Page`, not `Array`. `Page` is `Enumerable` + `size` / `empty?` / `to_a`, so `.each`, `.map`, `.first`, `.count`, `.select`, `.find` keep working. Callers relying on raw `Array` behaviour (`<<`, `[0..2]`, `push`, mutation, `JSON.dump(page)`, `page.is_a?(Array)`) should call `.to_a`.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- `ResponseHandler` now lets `304 Not Modified` through instead of raising.
|
|
19
|
+
|
|
3
20
|
## [0.2.1] - 2026-04-17
|
|
4
21
|
|
|
5
22
|
### Changed
|
data/README.md
CHANGED
|
@@ -22,6 +22,10 @@ problem.
|
|
|
22
22
|
- **Accounting resources**: fetch contacts, organisations, users, branding
|
|
23
23
|
themes, and nested objects like addresses, phones, external links, and payment
|
|
24
24
|
terms — all wrapped in proper value objects.
|
|
25
|
+
- **Querying**: every list endpoint accepts `where` / `order` / `page` /
|
|
26
|
+
`modified_since`, with a typed hash DSL (`where: { status: "AUTHORISED" }`)
|
|
27
|
+
and raw-string escape hatches. Lazy `each_<resource>` helpers walk every
|
|
28
|
+
page for whole-tenant scans and incremental syncs.
|
|
25
29
|
|
|
26
30
|
## Installation
|
|
27
31
|
|
|
@@ -72,6 +76,7 @@ below.
|
|
|
72
76
|
| [Errors](docs/errors.md) | The error hierarchy, what to catch and when |
|
|
73
77
|
| [Retries and rate limits](docs/retries-and-rate-limits.md) | How Xero Kiwi handles 429s and transient failures, customising the retry policy |
|
|
74
78
|
| [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, and `each_*` lazy pagination helpers |
|
|
75
80
|
|
|
76
81
|
## Status
|
|
77
82
|
|
data/Rakefile
CHANGED
|
@@ -34,6 +34,7 @@ LLMS_SOURCE_FILES = %w[
|
|
|
34
34
|
docs/accounting/payment-terms.md
|
|
35
35
|
docs/errors.md
|
|
36
36
|
docs/retries-and-rate-limits.md
|
|
37
|
+
docs/querying.md
|
|
37
38
|
].freeze
|
|
38
39
|
|
|
39
40
|
LLMS_FULL_PATH = "llms-full.txt"
|
|
@@ -43,7 +44,7 @@ LLMS_FULL_PATH = "llms-full.txt"
|
|
|
43
44
|
# `llms:check` use this so the two tasks can never disagree about the
|
|
44
45
|
# expected output.
|
|
45
46
|
def build_llms_full
|
|
46
|
-
out =
|
|
47
|
+
out = String.new(encoding: "UTF-8")
|
|
47
48
|
out << "# Xero Kiwi — full documentation\n\n"
|
|
48
49
|
out << "This file is the complete documentation for the Xero Kiwi gem (a Ruby wrapper for the Xero Accounting API), assembled into a single document for LLM consumption. It contains the README and every doc in the docs/ folder, in reading order.\n\n"
|
|
49
50
|
out << "For the curated index version, see llms.txt in the same directory.\n\n"
|
|
@@ -70,7 +71,7 @@ namespace :llms do
|
|
|
70
71
|
desc "Verify llms-full.txt is up to date with README and docs/"
|
|
71
72
|
task :check do
|
|
72
73
|
expected = build_llms_full
|
|
73
|
-
actual = File.exist?(LLMS_FULL_PATH) ? File.read(LLMS_FULL_PATH) : ""
|
|
74
|
+
actual = File.exist?(LLMS_FULL_PATH) ? File.read(LLMS_FULL_PATH, encoding: "UTF-8") : ""
|
|
74
75
|
|
|
75
76
|
if expected == actual
|
|
76
77
|
puts "✓ #{LLMS_FULL_PATH} is up to date"
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
# 0.3.0 — Querying (filter, order, page, modified-since)
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
0.2.1 shipped the attribute DSL + `identity` + `Hydrator` — the ground is
|
|
6
|
+
prepared. This release teaches the nine accounting list endpoints how to speak
|
|
7
|
+
Xero's query surface: the `where` / `order` / `page` query params and the
|
|
8
|
+
`If-Modified-Since` header. Today every list call hits the bare endpoint with
|
|
9
|
+
only `Xero-Tenant-Id`, forcing callers who want filtering or incremental sync
|
|
10
|
+
to drop out of kiwi and issue raw HTTP.
|
|
11
|
+
|
|
12
|
+
Shipping as **0.3.0**. Breaking, because list methods will return a new `Page`
|
|
13
|
+
object instead of a raw `Array`. `Page` is `Enumerable` + `size` + `to_a`, so
|
|
14
|
+
`.each`, `.map`, `.first`, `.select`, `.count` all keep working. Callers
|
|
15
|
+
relying on Array-specific behaviour (`<<`, `[0..2]`, `push`, mutation,
|
|
16
|
+
`JSON.dump`, `is_a?(Array)`) need `.to_a`.
|
|
17
|
+
|
|
18
|
+
## Decisions locked in
|
|
19
|
+
|
|
20
|
+
- **Return type:** `XeroKiwi::Page` (Enumerable) — items + pagination
|
|
21
|
+
metadata.
|
|
22
|
+
- **Filter compiler:** field-type-aware, driven off the DSL. Unknown keys
|
|
23
|
+
raise `ArgumentError`. Raw-string escape hatch for anything the hash can't
|
|
24
|
+
express (OR across fields, `LIKE`, `StartsWith`, …).
|
|
25
|
+
- **Order:** accept `Hash` (typed) or `String` (raw passthrough). Same field
|
|
26
|
+
map as the filter.
|
|
27
|
+
- **Pagination:** explicit `page:` kwarg plus `each_<resource>` lazy
|
|
28
|
+
Enumerator helper.
|
|
29
|
+
- **Modified-since:** `modified_since: Time` → `If-Modified-Since` header.
|
|
30
|
+
`304 Not Modified` returns an empty `Page` (no flag) — callers treat it
|
|
31
|
+
identically to an empty filter result.
|
|
32
|
+
- **Query schema inheritance:** `identity :xxx_id` auto-registers that
|
|
33
|
+
attribute in the query schema. Other queryable fields still need
|
|
34
|
+
`query: true`. Saves repeating `query: true` on every ID across 11
|
|
35
|
+
identity classes.
|
|
36
|
+
- **Writers: out of scope.** Reader-only, as before. DSL stays named neutral.
|
|
37
|
+
|
|
38
|
+
## Design
|
|
39
|
+
|
|
40
|
+
### 1. Extend the `attribute` DSL
|
|
41
|
+
|
|
42
|
+
Two small additions in `lib/xero_kiwi/accounting/resource.rb`:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
def attribute(name, xero:, type: :string, of: nil, reference: false,
|
|
46
|
+
hydrate: nil, query: false)
|
|
47
|
+
attributes[name] = {
|
|
48
|
+
xero: xero, type: type, of: of,
|
|
49
|
+
reference: reference, hydrate: hydrate, query: query
|
|
50
|
+
}
|
|
51
|
+
attr_reader name
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def query_fields
|
|
55
|
+
@_query_fields ||= attributes.each_with_object({}) do |(name, spec), acc|
|
|
56
|
+
next unless spec[:query] || identity_attributes&.include?(name)
|
|
57
|
+
|
|
58
|
+
acc[name] = build_query_field(spec)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
String `of:` refs resolve lazily, same as hydration — extract a shared
|
|
64
|
+
`resolve_class` helper the DSL and Hydrator both use.
|
|
65
|
+
|
|
66
|
+
### 2. Mark queryable fields on listable resources
|
|
67
|
+
|
|
68
|
+
Only the 9 listable resources need `query: true` additions beyond `identity`.
|
|
69
|
+
Example shape:
|
|
70
|
+
|
|
71
|
+
```ruby
|
|
72
|
+
class Invoice
|
|
73
|
+
include Accounting::Resource
|
|
74
|
+
|
|
75
|
+
payload_key "Invoices"
|
|
76
|
+
identity :invoice_id # auto-queryable
|
|
77
|
+
|
|
78
|
+
attribute :invoice_id, xero: "InvoiceID", type: :guid
|
|
79
|
+
attribute :invoice_number, xero: "InvoiceNumber", type: :string, query: true
|
|
80
|
+
attribute :type, xero: "Type", type: :enum, query: true
|
|
81
|
+
attribute :status, xero: "Status", type: :enum, query: true
|
|
82
|
+
attribute :date, xero: "Date", type: :date, query: true
|
|
83
|
+
attribute :updated_date_utc, xero: "UpdatedDateUTC", type: :date, query: true
|
|
84
|
+
attribute :contact, xero: "Contact", type: :object,
|
|
85
|
+
of: Contact, reference: true, query: true
|
|
86
|
+
# embedded collections (line_items, payments, credit_notes, …) stay
|
|
87
|
+
# query: false — Xero's where grammar doesn't filter on them.
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Nested queries (`Contact.ContactID`) fall out automatically: when a `:object`
|
|
92
|
+
attribute with `query: true` resolves, `build_query_field` walks into the
|
|
93
|
+
child's `query_fields` (which already exposes the identity ID on Contact).
|
|
94
|
+
|
|
95
|
+
### 3. Filter compiler — `XeroKiwi::Query::Filter`
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
XeroKiwi::Query::Filter.compile(where, fields: Invoice.query_fields)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Rules:
|
|
102
|
+
|
|
103
|
+
- `nil` → `nil`.
|
|
104
|
+
- `String` → passthrough (escape hatch).
|
|
105
|
+
- `Hash` → walk; each pair → `Path==Literal`, joined by `&&`.
|
|
106
|
+
- `Array` value → `(Path==x || Path==y)` (IN semantics).
|
|
107
|
+
- `Range` value → `Path>=lo && Path<=hi` (date ranges).
|
|
108
|
+
- `Hash` value on a `:nested` field → recurse with dotted prefix.
|
|
109
|
+
- Unknown key → `ArgumentError` naming the key.
|
|
110
|
+
|
|
111
|
+
Literal formatting per type:
|
|
112
|
+
|
|
113
|
+
- `:guid` → `Guid("…")`
|
|
114
|
+
- `:date` → `DateTime(y,m,d)` in UTC
|
|
115
|
+
- `:string`/`:enum` → `"…"` with `"` / `\` escaping
|
|
116
|
+
- `:bool` → `true` / `false`
|
|
117
|
+
- `:decimal` → `to_s`
|
|
118
|
+
|
|
119
|
+
### 4. Order compiler — `XeroKiwi::Query::Order`
|
|
120
|
+
|
|
121
|
+
Shares the `query_fields` map.
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
Order.compile({ date: :desc, invoice_number: :asc }, fields: …)
|
|
125
|
+
# => "Date DESC,InvoiceNumber ASC"
|
|
126
|
+
Order.compile("Date DESC", fields: …) # passthrough
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 5. `XeroKiwi::Page`
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
class Page
|
|
133
|
+
include Enumerable
|
|
134
|
+
attr_reader :items, :page, :page_size, :item_count, :total_count
|
|
135
|
+
|
|
136
|
+
def each(&b) = @items.each(&b)
|
|
137
|
+
def size = @items.size
|
|
138
|
+
def empty? = @items.empty?
|
|
139
|
+
def to_a = @items.dup
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Built in the client from Xero's `response.body["pagination"]` envelope
|
|
144
|
+
(`page`, `pageSize`, `itemCount`, `pageCount`). When absent (non-paged
|
|
145
|
+
endpoints like `BrandingThemes`, or a 304), fall back to `page: 1,
|
|
146
|
+
page_size: items.size, item_count: items.size, total_count: nil`.
|
|
147
|
+
|
|
148
|
+
### 6. Client surface
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
def invoices(tenant_id, where: nil, order: nil, page: nil, modified_since: nil)
|
|
152
|
+
list_request(
|
|
153
|
+
path: "/api.xro/2.0/Invoices",
|
|
154
|
+
tenant_id: tenant_id,
|
|
155
|
+
resource_class: Accounting::Invoice,
|
|
156
|
+
where: where, order: order, page: page, modified_since: modified_since
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def each_invoice(tenant_id, where: nil, order: nil, modified_since: nil, &block)
|
|
161
|
+
return to_enum(__method__, tenant_id, where: where, order: order,
|
|
162
|
+
modified_since: modified_since) unless block
|
|
163
|
+
|
|
164
|
+
(1..Float::INFINITY).lazy.each do |p|
|
|
165
|
+
pg = invoices(tenant_id, where: where, order: order, page: p,
|
|
166
|
+
modified_since: modified_since)
|
|
167
|
+
break if pg.empty?
|
|
168
|
+
pg.each(&block)
|
|
169
|
+
break if pg.page_size && pg.size < pg.page_size
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### 7. Shared `list_request` helper
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
private
|
|
178
|
+
|
|
179
|
+
def list_request(path:, tenant_id:, resource_class:,
|
|
180
|
+
where:, order:, page:, modified_since:)
|
|
181
|
+
tid = extract_tenant_id(tenant_id)
|
|
182
|
+
raise ArgumentError, "tenant_id is required" if tid.nil? || tid.empty?
|
|
183
|
+
|
|
184
|
+
fields = resource_class.query_fields
|
|
185
|
+
params = {}
|
|
186
|
+
params["where"] = Query::Filter.compile(where, fields: fields) if where
|
|
187
|
+
params["order"] = Query::Order.compile(order, fields: fields) if order
|
|
188
|
+
params["page"] = page if page
|
|
189
|
+
|
|
190
|
+
with_authenticated_request do
|
|
191
|
+
response = http.get(path, params) do |req|
|
|
192
|
+
req.headers["Xero-Tenant-Id"] = tid
|
|
193
|
+
req.headers["If-Modified-Since"] = modified_since.utc.httpdate if modified_since
|
|
194
|
+
end
|
|
195
|
+
build_page(response, resource_class)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### 8. 304 handling
|
|
201
|
+
|
|
202
|
+
`ResponseHandler` currently raises on anything outside 200–299. Extend the
|
|
203
|
+
passthrough so 304 reaches `build_page`, which returns an empty `Page`:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
return if (200..299).cover?(env.status) || env.status == 304
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
No exception, no flag — an empty page after `modified_since:` is
|
|
210
|
+
intentionally indistinguishable from an empty filter result.
|
|
211
|
+
|
|
212
|
+
### 9. Breaking-change surface
|
|
213
|
+
|
|
214
|
+
- Every list method returns `Page`, not `Array`.
|
|
215
|
+
- `Page` includes `Enumerable` + `size`, `empty?`, `to_a` — `.each`, `.map`,
|
|
216
|
+
`.first`, `.count`, `.select`, `.find` keep working.
|
|
217
|
+
- Breaks: `<<`, `[0..2]`, `push`, in-place mutation, `JSON.dump(page)`,
|
|
218
|
+
`is_a?(Array)` checks. Fix: `.to_a`.
|
|
219
|
+
- `Resource.from_response` unchanged; Page construction lives in the client.
|
|
220
|
+
|
|
221
|
+
## Files
|
|
222
|
+
|
|
223
|
+
### Create
|
|
224
|
+
|
|
225
|
+
- `lib/xero_kiwi/page.rb`
|
|
226
|
+
- `lib/xero_kiwi/query.rb` (autoloads the compilers)
|
|
227
|
+
- `lib/xero_kiwi/query/filter.rb`
|
|
228
|
+
- `lib/xero_kiwi/query/order.rb`
|
|
229
|
+
- `spec/xero_kiwi/page_spec.rb`
|
|
230
|
+
- `spec/xero_kiwi/query/filter_spec.rb`
|
|
231
|
+
- `spec/xero_kiwi/query/order_spec.rb`
|
|
232
|
+
- `docs/querying.md`
|
|
233
|
+
|
|
234
|
+
### Modify
|
|
235
|
+
|
|
236
|
+
- `lib/xero_kiwi.rb` — require new files.
|
|
237
|
+
- `lib/xero_kiwi/accounting/resource.rb` — `query:` kwarg + `query_fields`
|
|
238
|
+
class method honouring `identity_attributes`; share `resolve_class`.
|
|
239
|
+
- `lib/xero_kiwi/accounting/hydrator.rb` — use the shared `resolve_class`.
|
|
240
|
+
- `lib/xero_kiwi/accounting/{contact,invoice,credit_note,payment,prepayment,overpayment,user,branding_theme,contact_group}.rb`
|
|
241
|
+
— annotate queryable attributes.
|
|
242
|
+
- `lib/xero_kiwi/client.rb` — add `list_request` + `build_page`, rewrite the
|
|
243
|
+
9 list methods, add `each_*` helpers, let 304 through `ResponseHandler`.
|
|
244
|
+
- `lib/xero_kiwi/version.rb` — bump to `0.3.0`.
|
|
245
|
+
- `CHANGELOG.md` — **Breaking** (Page return type), **Added** (query params,
|
|
246
|
+
`each_*`, `modified_since`).
|
|
247
|
+
- `README.md` — teaser + link to `docs/querying.md`.
|
|
248
|
+
- `docs/accounting/*.md` — short "Query options" section per resource.
|
|
249
|
+
- `spec/xero_kiwi/accounting/*_spec.rb` — `query_fields` assertions per
|
|
250
|
+
listable resource.
|
|
251
|
+
- `spec/xero_kiwi/client_spec.rb` — new contexts per list method; VCR
|
|
252
|
+
cassettes for filtered/paged calls; WebMock stubs for 304 paths.
|
|
253
|
+
- `llms-full.txt` — regenerate.
|
|
254
|
+
|
|
255
|
+
## Testing strategy
|
|
256
|
+
|
|
257
|
+
- **Filter / Order compilers:** pure unit tests, no HTTP. Table-driven
|
|
258
|
+
`{input, fields, expected}` covering scalar eq, Array (IN), Range, nested
|
|
259
|
+
Hash, raw String passthrough, unknown-key raises, per-type literal
|
|
260
|
+
formatting.
|
|
261
|
+
- **`query_fields` mixin logic:** declare a throwaway resource with and
|
|
262
|
+
without `identity`; confirm the merged map contains identity + explicit
|
|
263
|
+
`query: true` attributes and nothing else.
|
|
264
|
+
- **Page:** Enumerable delegation, pagination envelope parsing, fallback
|
|
265
|
+
when envelope absent, 304-empty behaviour.
|
|
266
|
+
- **Client integration:** WebMock stubs asserting exact URL and headers;
|
|
267
|
+
one VCR cassette per listable resource for filtered + paged calls; one
|
|
268
|
+
WebMock stub per resource for 304; `each_invoice` lazy test with stubbed
|
|
269
|
+
pages 1..3 where page 3 short-circuits empty.
|
|
270
|
+
|
|
271
|
+
## Verification
|
|
272
|
+
|
|
273
|
+
```sh
|
|
274
|
+
bundle exec rspec # full suite green
|
|
275
|
+
bundle exec rspec spec/xero_kiwi/query # new compiler specs
|
|
276
|
+
bundle exec rspec spec/xero_kiwi/client_spec.rb # integration
|
|
277
|
+
bundle exec rubocop # style clean
|
|
278
|
+
bundle exec rake llms:check # docs in sync
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
Smoke test:
|
|
282
|
+
|
|
283
|
+
```ruby
|
|
284
|
+
client = XeroKiwi::Client.new(access_token: ENV.fetch("XERO_TOKEN"))
|
|
285
|
+
|
|
286
|
+
page = client.invoices(
|
|
287
|
+
tenant,
|
|
288
|
+
where: { status: "AUTHORISED", contact: { contact_id: "abc-123" } },
|
|
289
|
+
order: { date: :desc },
|
|
290
|
+
page: 1
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
client.each_invoice(tenant, where: { status: "AUTHORISED" }).first(250).count
|
|
294
|
+
client.invoices(tenant, modified_since: 1.day.ago).empty?
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Execution order
|
|
298
|
+
|
|
299
|
+
1. Extend the DSL: `query:` + `query_fields` + shared `resolve_class`.
|
|
300
|
+
2. Annotate the 9 listable resources.
|
|
301
|
+
3. Add `Query::Filter` / `Query::Order` compilers + specs.
|
|
302
|
+
4. Add `Page` + specs.
|
|
303
|
+
5. Let 304 through `ResponseHandler`.
|
|
304
|
+
6. Add `list_request` + `build_page` on the client; rewrite the 9 list
|
|
305
|
+
methods.
|
|
306
|
+
7. Add the 9 `each_*` helpers.
|
|
307
|
+
8. Write `docs/querying.md`, update per-resource docs + README.
|
|
308
|
+
9. Bump version, update changelog, regenerate `llms-full.txt`.
|
|
309
|
+
10. Run full suite + rubocop between stages.
|
data/docs/querying.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# Querying
|
|
2
|
+
|
|
3
|
+
Every accounting list endpoint supports four optional query-time features:
|
|
4
|
+
|
|
5
|
+
- **`where:`** — filter expressions
|
|
6
|
+
- **`order:`** — sorting
|
|
7
|
+
- **`page:`** — pagination
|
|
8
|
+
- **`modified_since:`** — conditional GET via `If-Modified-Side` header
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
page = client.invoices(
|
|
12
|
+
tenant,
|
|
13
|
+
where: { status: "AUTHORISED", date: Date.new(2026, 1, 1)..Date.new(2026, 4, 1) },
|
|
14
|
+
order: { date: :desc },
|
|
15
|
+
page: 1
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
page.size # => 100
|
|
19
|
+
page.page # => 1
|
|
20
|
+
page.page_size # => 100
|
|
21
|
+
page.each { |invoice| … }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Return type: `XeroKiwi::Page`
|
|
25
|
+
|
|
26
|
+
List methods return a `XeroKiwi::Page` — an `Enumerable`-backed wrapper
|
|
27
|
+
exposing items plus pagination metadata:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
page = client.invoices(tenant)
|
|
31
|
+
|
|
32
|
+
page.each { |inv| … } # Enumerable
|
|
33
|
+
page.map { |inv| … } # Enumerable
|
|
34
|
+
page.first # first item
|
|
35
|
+
page.size # 100
|
|
36
|
+
page.empty? # false
|
|
37
|
+
page.to_a # raw Array
|
|
38
|
+
|
|
39
|
+
page.page # which page number was returned
|
|
40
|
+
page.page_size # how many items per page (Xero's default is 100)
|
|
41
|
+
page.item_count # how many items are on this page
|
|
42
|
+
page.total_count # total item count across all pages (when Xero reports it)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Because `Page` includes `Enumerable` plus `size` / `empty?` / `to_a`, the
|
|
46
|
+
common idioms (`.map`, `.select`, `.first`, `.count`) keep working. Callers
|
|
47
|
+
that need raw `Array` behaviour (`<<`, slicing, `JSON.dump`,
|
|
48
|
+
`is_a?(Array)`) call `.to_a`.
|
|
49
|
+
|
|
50
|
+
## `where:` — filtering
|
|
51
|
+
|
|
52
|
+
Two shapes are supported.
|
|
53
|
+
|
|
54
|
+
### Hash (recommended)
|
|
55
|
+
|
|
56
|
+
Kiwi owns the quoting and literal syntax. Field names are the snake-case
|
|
57
|
+
Ruby attribute names.
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
client.invoices(tenant, where: { status: "AUTHORISED" })
|
|
61
|
+
# emits: Status=="AUTHORISED"
|
|
62
|
+
|
|
63
|
+
client.invoices(tenant, where: { status: "AUTHORISED", type: "ACCREC" })
|
|
64
|
+
# joined with &&
|
|
65
|
+
|
|
66
|
+
client.invoices(tenant, where: { status: %w[AUTHORISED DRAFT] })
|
|
67
|
+
# Array value → IN-semantics: (Status=="AUTHORISED" || Status=="DRAFT")
|
|
68
|
+
|
|
69
|
+
client.invoices(tenant, where: { date: Date.new(2026, 1, 1)..Date.new(2026, 4, 1) })
|
|
70
|
+
# Range value → Date>=DateTime(2026,1,1) && Date<=DateTime(2026,4,1)
|
|
71
|
+
|
|
72
|
+
client.invoices(tenant, where: { contact: { contact_id: "abc-123" } })
|
|
73
|
+
# Hash value on a nested object → Contact.ContactID==Guid("abc-123")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Literal formatting per field type (declared in the resource class):
|
|
77
|
+
|
|
78
|
+
| Type | Rendered as |
|
|
79
|
+
|------------|---------------------------|
|
|
80
|
+
| `:guid` | `Guid("…")` |
|
|
81
|
+
| `:date` | `DateTime(y,m,d)` in UTC |
|
|
82
|
+
| `:string` | `"…"` (escaped) |
|
|
83
|
+
| `:enum` | `"…"` (escaped) |
|
|
84
|
+
| `:bool` | `true` / `false` |
|
|
85
|
+
| `:decimal` | `99.5` |
|
|
86
|
+
|
|
87
|
+
Unknown field names raise `ArgumentError` so typos surface at the call
|
|
88
|
+
site rather than producing broken Xero queries.
|
|
89
|
+
|
|
90
|
+
### Raw String (escape hatch)
|
|
91
|
+
|
|
92
|
+
When the hash form can't express something (OR across different fields,
|
|
93
|
+
`LIKE`, `StartsWith`, etc.), pass a raw string — kiwi passes it straight
|
|
94
|
+
through.
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
client.invoices(
|
|
98
|
+
tenant,
|
|
99
|
+
where: 'Status=="AUTHORISED" || Status=="DRAFT"'
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Consult Xero's [filter docs][xero-filters] for the full grammar.
|
|
104
|
+
|
|
105
|
+
[xero-filters]: https://developer.xero.com/documentation/api/accounting/requests-and-responses#retrieving-a-filtered-resource
|
|
106
|
+
|
|
107
|
+
## `order:` — sorting
|
|
108
|
+
|
|
109
|
+
Hash (typed) or string (raw passthrough).
|
|
110
|
+
|
|
111
|
+
```ruby
|
|
112
|
+
client.invoices(tenant, order: { date: :desc })
|
|
113
|
+
# => order=Date DESC
|
|
114
|
+
|
|
115
|
+
client.invoices(tenant, order: { date: :desc, invoice_number: :asc })
|
|
116
|
+
# => order=Date DESC,InvoiceNumber ASC
|
|
117
|
+
|
|
118
|
+
client.invoices(tenant, order: "Date DESC")
|
|
119
|
+
# passthrough
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## `page:` — pagination
|
|
123
|
+
|
|
124
|
+
Maps directly to Xero's `page` query param (1-indexed). Xero's page size
|
|
125
|
+
is 100 items for paginated endpoints.
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
client.invoices(tenant, page: 2).size # => up to 100
|
|
129
|
+
client.invoices(tenant, page: 2).page_size # => 100
|
|
130
|
+
client.invoices(tenant, page: 2).item_count # total-on-this-page
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Walking every page — `each_<resource>`
|
|
134
|
+
|
|
135
|
+
For incremental syncs or whole-tenant scans, use the `each_*` helpers.
|
|
136
|
+
They take the same kwargs as the list method (minus `page:`), return a
|
|
137
|
+
lazy Enumerator when no block is given, and short-circuit when a short
|
|
138
|
+
page indicates no more data.
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
client.each_invoice(tenant, where: { status: "AUTHORISED" }) do |invoice|
|
|
142
|
+
Sync.upsert(invoice)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Or use the Enumerator:
|
|
146
|
+
client.each_invoice(tenant, order: { date: :desc })
|
|
147
|
+
.first(250)
|
|
148
|
+
.map(&:invoice_id)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Available for every listable resource: `each_user`, `each_contact`,
|
|
152
|
+
`each_contact_group`, `each_invoice`, `each_credit_note`, `each_payment`,
|
|
153
|
+
`each_prepayment`, `each_overpayment`, `each_branding_theme`.
|
|
154
|
+
|
|
155
|
+
## `modified_since:` — incremental sync
|
|
156
|
+
|
|
157
|
+
Pass a `Time`; kiwi sends it as Xero's `If-Modified-Since` header in RFC
|
|
158
|
+
1123 format.
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
page = client.invoices(tenant, modified_since: 1.day.ago)
|
|
162
|
+
|
|
163
|
+
page.each { |invoice| … }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
If Xero returns `304 Not Modified`, kiwi returns an empty `Page` — no
|
|
167
|
+
exception, no special flag. An empty page after `modified_since:` is
|
|
168
|
+
indistinguishable from a filter that matched nothing (intentional — the
|
|
169
|
+
caller can treat them identically).
|
|
170
|
+
|
|
171
|
+
## Combining everything
|
|
172
|
+
|
|
173
|
+
Mix and match freely:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
client.invoices(
|
|
177
|
+
tenant,
|
|
178
|
+
where: { status: "AUTHORISED", contact: { contact_id: "abc-123" } },
|
|
179
|
+
order: { date: :desc },
|
|
180
|
+
page: 1,
|
|
181
|
+
modified_since: last_sync_at
|
|
182
|
+
)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## What's queryable per resource?
|
|
186
|
+
|
|
187
|
+
Queryable fields are declared via `query: true` on each resource class's
|
|
188
|
+
`attribute` declarations. Identity fields (`invoice_id`, `contact_id`,
|
|
189
|
+
etc.) are queryable automatically.
|
|
190
|
+
|
|
191
|
+
You can inspect a resource's queryable fields at runtime:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
XeroKiwi::Accounting::Invoice.query_fields.keys
|
|
195
|
+
# => [:invoice_id, :invoice_number, :type, :contact, :date, :due_date,
|
|
196
|
+
# :status, :updated_date_utc, :reference]
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
See the per-resource docs under `docs/accounting/` for the canonical list.
|
|
@@ -12,7 +12,7 @@ module XeroKiwi
|
|
|
12
12
|
identity :branding_theme_id
|
|
13
13
|
|
|
14
14
|
attribute :branding_theme_id, xero: "BrandingThemeID", type: :guid
|
|
15
|
-
attribute :name, xero: "Name"
|
|
15
|
+
attribute :name, xero: "Name", query: true
|
|
16
16
|
attribute :logo_url, xero: "LogoUrl"
|
|
17
17
|
attribute :type, xero: "Type"
|
|
18
18
|
attribute :sort_order, xero: "SortOrder"
|
|
@@ -17,12 +17,12 @@ module XeroKiwi
|
|
|
17
17
|
|
|
18
18
|
attribute :contact_id, xero: "ContactID", type: :guid
|
|
19
19
|
attribute :contact_number, xero: "ContactNumber"
|
|
20
|
-
attribute :account_number, xero: "AccountNumber"
|
|
21
|
-
attribute :contact_status, xero: "ContactStatus", type: :enum
|
|
22
|
-
attribute :name, xero: "Name"
|
|
23
|
-
attribute :first_name, xero: "FirstName"
|
|
24
|
-
attribute :last_name, xero: "LastName"
|
|
25
|
-
attribute :email_address, xero: "EmailAddress"
|
|
20
|
+
attribute :account_number, xero: "AccountNumber", query: true
|
|
21
|
+
attribute :contact_status, xero: "ContactStatus", type: :enum, query: true
|
|
22
|
+
attribute :name, xero: "Name", query: true
|
|
23
|
+
attribute :first_name, xero: "FirstName", query: true
|
|
24
|
+
attribute :last_name, xero: "LastName", query: true
|
|
25
|
+
attribute :email_address, xero: "EmailAddress", query: true
|
|
26
26
|
attribute :bank_account_details, xero: "BankAccountDetails"
|
|
27
27
|
attribute :company_number, xero: "CompanyNumber"
|
|
28
28
|
attribute :tax_number, xero: "TaxNumber"
|
|
@@ -31,10 +31,10 @@ module XeroKiwi
|
|
|
31
31
|
attribute :accounts_payable_tax_type, xero: "AccountsPayableTaxType"
|
|
32
32
|
attribute :addresses, xero: "Addresses", type: :collection, of: Address
|
|
33
33
|
attribute :phones, xero: "Phones", type: :collection, of: Phone
|
|
34
|
-
attribute :is_supplier, xero: "IsSupplier", type: :bool
|
|
35
|
-
attribute :is_customer, xero: "IsCustomer", type: :bool
|
|
34
|
+
attribute :is_supplier, xero: "IsSupplier", type: :bool, query: true
|
|
35
|
+
attribute :is_customer, xero: "IsCustomer", type: :bool, query: true
|
|
36
36
|
attribute :default_currency, xero: "DefaultCurrency"
|
|
37
|
-
attribute :updated_date_utc, xero: "UpdatedDateUTC", type: :date
|
|
37
|
+
attribute :updated_date_utc, xero: "UpdatedDateUTC", type: :date, query: true
|
|
38
38
|
attribute :contact_persons, xero: "ContactPersons", type: :collection, of: ContactPerson
|
|
39
39
|
attribute :xero_network_key, xero: "XeroNetworkKey"
|
|
40
40
|
attribute :merged_to_contact_id, xero: "MergedToContactID", type: :guid
|
|
@@ -12,8 +12,8 @@ module XeroKiwi
|
|
|
12
12
|
identity :contact_group_id
|
|
13
13
|
|
|
14
14
|
attribute :contact_group_id, xero: "ContactGroupID", type: :guid
|
|
15
|
-
attribute :name, xero: "Name"
|
|
16
|
-
attribute :status, xero: "Status"
|
|
15
|
+
attribute :name, xero: "Name", query: true
|
|
16
|
+
attribute :status, xero: "Status", query: true
|
|
17
17
|
attribute :contacts, xero: "Contacts", type: :collection, of: Contact, reference: true
|
|
18
18
|
|
|
19
19
|
def active? = status == "ACTIVE"
|
|
@@ -13,17 +13,17 @@ module XeroKiwi
|
|
|
13
13
|
|
|
14
14
|
attribute :credit_note_id, xero: "CreditNoteID", type: :guid
|
|
15
15
|
attribute :credit_note_number, xero: "CreditNoteNumber"
|
|
16
|
-
attribute :type, xero: "Type", type: :enum
|
|
17
|
-
attribute :contact, xero: "Contact", type: :object, of: Contact, reference: true
|
|
18
|
-
attribute :date, xero: "Date", type: :date
|
|
19
|
-
attribute :status, xero: "Status", type: :enum
|
|
16
|
+
attribute :type, xero: "Type", type: :enum, query: true
|
|
17
|
+
attribute :contact, xero: "Contact", type: :object, of: Contact, reference: true, query: true
|
|
18
|
+
attribute :date, xero: "Date", type: :date, query: true
|
|
19
|
+
attribute :status, xero: "Status", type: :enum, query: true
|
|
20
20
|
attribute :line_amount_types, xero: "LineAmountTypes"
|
|
21
21
|
attribute :line_items, xero: "LineItems", type: :collection, of: LineItem
|
|
22
22
|
attribute :sub_total, xero: "SubTotal", type: :decimal
|
|
23
23
|
attribute :total_tax, xero: "TotalTax", type: :decimal
|
|
24
24
|
attribute :total, xero: "Total", type: :decimal
|
|
25
25
|
attribute :cis_deduction, xero: "CISDeduction", type: :decimal
|
|
26
|
-
attribute :updated_date_utc, xero: "UpdatedDateUTC", type: :date
|
|
26
|
+
attribute :updated_date_utc, xero: "UpdatedDateUTC", type: :date, query: true
|
|
27
27
|
attribute :currency_code, xero: "CurrencyCode"
|
|
28
28
|
attribute :currency_rate, xero: "CurrencyRate", type: :decimal
|
|
29
29
|
attribute :fully_paid_on_date, xero: "FullyPaidOnDate", type: :date
|