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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4deb1de10e85de555f19546d2d2243135b2f95fff462e45a2cac72e8622a9e31
4
- data.tar.gz: 46dfc6f3e6eeeda92546e95b3161aea9802fb401e823d0f2ca67d91bab3555d7
3
+ metadata.gz: ec24e1de644ead6640027a7d62ac1df0eda8677866e2f76f9c4e65c6a36d3750
4
+ data.tar.gz: a35d014164892fe7aefda15f4c9549d8da82fc292c85f7fcd2e6edb8bdc27458
5
5
  SHA512:
6
- metadata.gz: 781f5c1cfde039f7746984185d218d2fa9e07ceb66cb660d7227291f7e45210f97f590a97cb0d24dfa7c265843c98a37b3d4fd857b41d6cd6ab7e53d1db8d646
7
- data.tar.gz: 727152993b2424d6513c44947db8b75cd4b1a8734b000e390644c59154ba72804cd48cf16b36e0df299df250b1b6a5eebeace82aba0319adc924a30125f5bdc6
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