buezli-api 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: '094aa7cb35ecc313611cd66a86c777431461d8a8c8fdc9e6508f0a47d54bf5f5'
4
+ data.tar.gz: e0750a2351cf57f9fe1911373c41fc4a04946da03ef24fbd0dc76225af289122
5
+ SHA512:
6
+ metadata.gz: 50b99f0e3d1aa8d1a9d5525f9aacbcb763347da6ad95835ea523794dfcb6020c22d72dd1bf812fb995142c4fa80342f51d5f5cf4483866cd2d2a3b2aab92bb91
7
+ data.tar.gz: 97e5011a61ac35e4867fccbbf9e022435ad2636b31bd7c3888090942c36bd198a996e2a92eba9050dfe560a1531e519b5b5d84a136d509bc296090fd1645edec
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Codegestalt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # buezli-api
2
+
3
+ The official schema-driven Ruby client for the read-only
4
+ [Buezli API](https://buezli.app/api). Buezli is straightforward invoicing,
5
+ time tracking, and project management for small service teams. Learn more at
6
+ [buezli.app](https://buezli.app).
7
+
8
+ The gem builds its methods from a bundled, date-versioned API schema. It
9
+ supports records, expansions, delta polling, pagination, PDFs, and receipts.
10
+
11
+ ## Install
12
+
13
+ ```ruby
14
+ gem "buezli-api"
15
+ ```
16
+
17
+ `buezli-api` requires Ruby 3.2 or newer.
18
+
19
+ ## Quick start
20
+
21
+ ```ruby
22
+ require "buezli/api"
23
+
24
+ client = Buezli::Api::Client.new(
25
+ access_token: ENV.fetch("BUEZLI_API_TOKEN")
26
+ )
27
+
28
+ invoices = client.retrieve_invoices(limit: 50)
29
+ invoice = client.retrieve_invoice(123, expand: ["client"])
30
+
31
+ invoice.id
32
+ invoice.issue_date # Date
33
+ invoice.updated_at # Time
34
+ invoice.total # { "cents" => 10810, "currency" => "CHF" }
35
+ invoice.client.name
36
+ invoice.line_items.first.description
37
+ ```
38
+
39
+ The default endpoint is `https://api.buezli.app`, and the newest bundled
40
+ schema is selected automatically. Pin an API version when needed:
41
+
42
+ ```ruby
43
+ client = Buezli::Api::Client.new(
44
+ access_token: ENV.fetch("BUEZLI_API_TOKEN"),
45
+ version: "2026-07-14"
46
+ )
47
+ ```
48
+
49
+ Schema-declared dates and timestamps become `Date` and `Time`. Decimal values
50
+ remain strings and money remains a `cents`/`currency` hash, avoiding precision
51
+ loss. Unknown record methods raise `NoMethodError`; declared but absent fields
52
+ return `nil`.
53
+
54
+ ## Pagination and deltas
55
+
56
+ Collections are enumerable and retain the original query when paging:
57
+
58
+ ```ruby
59
+ invoices = client.retrieve_invoices(
60
+ updated_since: Time.utc(2026, 7, 14, 10),
61
+ expand: ["client"],
62
+ page: 1,
63
+ limit: 100
64
+ )
65
+
66
+ invoices.each do |invoice|
67
+ invoice.deleted? ? remove_local(invoice.id) : replace_local(invoice.to_h)
68
+ end
69
+
70
+ invoices.pagination
71
+ next_page = invoices.next_page
72
+ previous_page = next_page&.previous_page
73
+ ```
74
+
75
+ ## Downloads
76
+
77
+ ```ruby
78
+ pdf = client.retrieve_invoice_pdf(123)
79
+ receipt = client.retrieve_expense_receipt(456)
80
+
81
+ pdf.body
82
+ pdf.filename
83
+ pdf.content_type
84
+ pdf.headers
85
+ ```
86
+
87
+ Save a download when needed:
88
+
89
+ ```ruby
90
+ File.binwrite(pdf.filename, pdf.body)
91
+ ```
92
+
93
+ ## Schema and errors
94
+
95
+ Inspect the selected API schema with `client.schema`, or fetch the schema
96
+ currently served by Buezli with `client.retrieve_schema`.
97
+
98
+ Failed requests raise `Buezli::Api::Client::Error`, exposing `status`, `code`,
99
+ `message`, `details`, `response_body`, and `headers`. Access tokens are never
100
+ included in errors or inspection output.
101
+
102
+ See the [API documentation](https://buezli.app/api) for available resources,
103
+ fields, expansions, and authentication.
104
+
105
+ ## License
106
+
107
+ [MIT](LICENSE)
@@ -0,0 +1,16 @@
1
+ module Buezli
2
+ module Api
3
+ class Client
4
+ class Download
5
+ attr_reader :body, :filename, :content_type, :headers
6
+
7
+ def initialize(body:, filename:, content_type:, headers:)
8
+ @body = body
9
+ @filename = filename
10
+ @content_type = content_type
11
+ @headers = headers
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,36 @@
1
+ module Buezli
2
+ module Api
3
+ class Client
4
+ class Error < StandardError
5
+ attr_reader :status, :code, :details, :response_body, :headers
6
+
7
+ def initialize(response)
8
+ if response.is_a?(String)
9
+ @details = {}
10
+ @headers = {}.with_indifferent_access
11
+ return super(response)
12
+ end
13
+
14
+ @status = response.code.to_i
15
+ @response_body = response.body.to_s
16
+ @headers = response.each_header.to_h.with_indifferent_access
17
+ parsed = parse_json(response_body)
18
+ @code = parsed&.dig("error", "code")
19
+ @details = parsed&.dig("error", "details") || {}
20
+ message = parsed&.dig("error", "message") || response_body.presence ||
21
+ "#{response.code} #{response.message}"
22
+
23
+ super(message)
24
+ end
25
+
26
+ private
27
+
28
+ def parse_json(body)
29
+ JSON.parse(body)
30
+ rescue JSON::ParserError, TypeError
31
+ nil
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,60 @@
1
+ module Buezli
2
+ module Api
3
+ class Client
4
+ class List
5
+ include Enumerable
6
+
7
+ attr_reader :data, :meta
8
+
9
+ def initialize(data:, meta:, client:, request_context:)
10
+ @data = data
11
+ @meta = (meta || {}).with_indifferent_access
12
+ @client = client
13
+ @request_context = request_context
14
+ end
15
+
16
+ def each(&block)
17
+ data.each(&block)
18
+ end
19
+
20
+ def empty?
21
+ data.empty?
22
+ end
23
+
24
+ def size
25
+ data.size
26
+ end
27
+
28
+ alias length size
29
+
30
+ def [](index)
31
+ data[index]
32
+ end
33
+
34
+ def pagination
35
+ meta.fetch(:pagination, {})
36
+ end
37
+
38
+ def next_page
39
+ request_page(pagination[:next])
40
+ end
41
+
42
+ def previous_page
43
+ request_page(pagination[:prev])
44
+ end
45
+
46
+ private
47
+
48
+ def request_page(page)
49
+ return if page.nil?
50
+
51
+ current_page = pagination[:page] || @request_context[:params][:page]
52
+ raise Error, "Infinite pagination loop detected" if current_page.to_s == page.to_s
53
+
54
+ params = @request_context[:params].merge(page:)
55
+ @client.send(:perform_request, **@request_context.merge(params:))
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,78 @@
1
+ module Buezli
2
+ module Api
3
+ class Client
4
+ class Record
5
+ attr_reader :raw_attributes, :model_name, :client, :schema
6
+
7
+ def initialize(attributes:, model_name:, client:, schema:)
8
+ @raw_attributes = (attributes || {}).with_indifferent_access
9
+ @model_name = model_name
10
+ @client = client
11
+ @schema = schema
12
+ end
13
+
14
+ def id
15
+ raw_attributes[:id]
16
+ end
17
+
18
+ def deleted?
19
+ raw_attributes[:_deleted] == true
20
+ end
21
+
22
+ def [](key)
23
+ raw_attributes[key]
24
+ end
25
+
26
+ def to_h
27
+ raw_attributes.to_h
28
+ end
29
+
30
+ def method_missing(method, *args, &block)
31
+ return super unless args.empty? && block.nil? && schema.declared_field?(model_name, method)
32
+
33
+ read_value(method.to_s)
34
+ end
35
+
36
+ def respond_to_missing?(method, include_private = false)
37
+ schema.declared_field?(model_name, method) || super
38
+ end
39
+
40
+ private
41
+
42
+ def read_value(name)
43
+ value = raw_attributes[name]
44
+ return build_association(name, value) if schema.association?(model_name, name)
45
+
46
+ case schema.attribute_type(model_name, name).to_s
47
+ when "datetime" then parse_time(value)
48
+ when "date" then parse_date(value)
49
+ else value
50
+ end
51
+ end
52
+
53
+ def build_association(name, value)
54
+ return if value.nil?
55
+
56
+ target_model = schema.association_class_name(model_name, name)
57
+ if schema.association_collection?(model_name, name)
58
+ Array(value).map { |attributes| client.build_record(target_model, attributes) }
59
+ else
60
+ client.build_record(target_model, value)
61
+ end
62
+ end
63
+
64
+ def parse_time(value)
65
+ Time.parse(value.to_s) if value.present?
66
+ rescue ArgumentError
67
+ value
68
+ end
69
+
70
+ def parse_date(value)
71
+ Date.parse(value.to_s) if value.present?
72
+ rescue Date::Error
73
+ value
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,168 @@
1
+ module Buezli
2
+ module Api
3
+ class Client
4
+ DEFAULT_ENDPOINT = "https://api.buezli.app"
5
+
6
+ attr_reader :access_token, :endpoint, :version, :schema, :host
7
+
8
+ def initialize(access_token:, endpoint: DEFAULT_ENDPOINT, version: nil, schema: nil, host: nil)
9
+ @access_token = access_token
10
+ @endpoint = endpoint.chomp("/")
11
+ @schema = schema || load_schema(version)
12
+ @version = version || @schema.version
13
+ @host = host
14
+ raise ArgumentError, "Schema version does not match requested version" if @schema.version != @version
15
+
16
+ define_methods_from_schema
17
+ end
18
+
19
+ def build_record(model_name, attributes)
20
+ Record.new(attributes:, model_name:, client: self, schema:)
21
+ end
22
+
23
+ def inspect
24
+ "#<#{self.class.name} endpoint=#{endpoint.inspect} version=#{version.inspect} host=#{host.inspect}>"
25
+ end
26
+
27
+ private
28
+
29
+ def load_schema(version)
30
+ requested_version = version || latest_schema_version
31
+ Schema.load(schema_directory.join("#{requested_version}.json"))
32
+ end
33
+
34
+ def schema_directory
35
+ Pathname(__dir__).join("../../../schemas").expand_path
36
+ end
37
+
38
+ def latest_schema_version
39
+ schema_directory.glob("*.json").map { |path| path.basename(".json").to_s }.max ||
40
+ raise(ArgumentError, "No bundled API schemas found")
41
+ end
42
+
43
+ def define_methods_from_schema
44
+ schema.endpoints.each do |resource, endpoints|
45
+ endpoints.each do |endpoint_config|
46
+ define_singleton_method(method_name_for(resource, endpoint_config.fetch("action"))) do |*path_values, **params|
47
+ perform_endpoint(resource, endpoint_config, path_values, params)
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ def method_name_for(resource, action)
54
+ return "retrieve_#{resource}" if action == "index"
55
+ return "retrieve_#{resource.singularize}" if action == "show"
56
+
57
+ "retrieve_#{resource.singularize}_#{action}"
58
+ end
59
+
60
+ def perform_endpoint(resource, endpoint_config, path_values, params)
61
+ path = endpoint_config.fetch("path").dup
62
+ query = params.with_indifferent_access
63
+
64
+ path.scan(/:(\w+)/).flatten.each do |key|
65
+ value = query.delete(key) || path_values.shift
66
+ raise ArgumentError, "Missing required path parameter: #{key}" if value.nil?
67
+
68
+ path.sub!(":#{key}", value.to_s)
69
+ end
70
+ raise ArgumentError, "Too many path parameters" if path_values.any?
71
+
72
+ perform_request(
73
+ resource:,
74
+ action: endpoint_config.fetch("action"),
75
+ verb: endpoint_config.fetch("verb"),
76
+ path:,
77
+ params: query
78
+ )
79
+ end
80
+
81
+ def perform_request(resource:, action:, verb:, path:, params:)
82
+ raise ArgumentError, "Unsupported API verb: #{verb}" unless verb == "GET"
83
+
84
+ uri = URI("#{endpoint}#{path}")
85
+ uri.query = encode_query(params) if params.present?
86
+ request = Net::HTTP::Get.new(uri)
87
+ request["Authorization"] = "Bearer #{access_token}"
88
+ request["Accept"] = "application/json, */*"
89
+ request["Host"] = host if host
90
+
91
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
92
+ http.request(request)
93
+ end
94
+ raise Error.new(response) unless response.is_a?(Net::HTTPSuccess)
95
+
96
+ parse_response(
97
+ response,
98
+ resource:,
99
+ request_context: { resource:, action:, verb:, path:, params: params.dup }
100
+ )
101
+ end
102
+
103
+ def parse_response(response, resource:, request_context:)
104
+ content_type = response["Content-Type"].to_s.split(";").first
105
+ return build_download(response) unless content_type == "application/json" || content_type.end_with?("+json")
106
+
107
+ payload = JSON.parse(response.body)
108
+ return Schema.new(payload) if resource == "schema"
109
+
110
+ data = payload.key?("data") ? payload["data"] : payload
111
+ model_name = schema.model_name_for_resource(resource)
112
+ return payload unless model_name
113
+
114
+ if data.is_a?(Array)
115
+ List.new(
116
+ data: data.map { |attributes| build_record(model_name, attributes) },
117
+ meta: payload["meta"],
118
+ client: self,
119
+ request_context:
120
+ )
121
+ elsif data.is_a?(Hash)
122
+ build_record(model_name, data)
123
+ else
124
+ data
125
+ end
126
+ end
127
+
128
+ def build_download(response)
129
+ headers = response.each_header.to_h.with_indifferent_access
130
+ Download.new(
131
+ body: response.body,
132
+ filename: response_filename(response["Content-Disposition"]),
133
+ content_type: response["Content-Type"].to_s.split(";").first,
134
+ headers:
135
+ )
136
+ end
137
+
138
+ def response_filename(disposition)
139
+ encoded = disposition.to_s[/filename\*=(?:UTF-8'')?([^;]+)/i, 1]
140
+ return URI.decode_www_form_component(encoded.delete_prefix('"').delete_suffix('"')) if encoded
141
+
142
+ disposition.to_s[/filename="([^"]+)"/i, 1] ||
143
+ disposition.to_s[/filename=([^;]+)/i, 1]&.strip
144
+ end
145
+
146
+ def encode_query(params, prefix = nil)
147
+ params.flat_map do |key, value|
148
+ full_key = prefix ? "#{prefix}[#{key}]" : key.to_s
149
+ if value.is_a?(Hash)
150
+ encode_query(value, full_key)
151
+ elsif value.is_a?(Array)
152
+ value.map { |item| encoded_pair("#{full_key}[]", item) }
153
+ else
154
+ encoded_pair(full_key, value)
155
+ end
156
+ end.join("&")
157
+ end
158
+
159
+ def encoded_pair(key, value)
160
+ encoded_value = case value
161
+ when Time, Date then value.iso8601
162
+ else value.to_s
163
+ end
164
+ "#{URI.encode_www_form_component(key)}=#{URI.encode_www_form_component(encoded_value)}"
165
+ end
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,57 @@
1
+ module Buezli
2
+ module Api
3
+ class Schema
4
+ attr_reader :version, :config
5
+
6
+ def self.load(path)
7
+ new(JSON.parse(Pathname(path).read))
8
+ end
9
+
10
+ def initialize(config)
11
+ @config = config.with_indifferent_access
12
+ @version = @config.fetch(:version)
13
+ end
14
+
15
+ def endpoints
16
+ config.fetch(:endpoints, {})
17
+ end
18
+
19
+ def for(model_name)
20
+ config.dig(:models, model_name.to_s)
21
+ end
22
+
23
+ def model_name_for_resource(resource_name)
24
+ config.fetch(:models).find do |_model_name, model|
25
+ model[:resource_name] == resource_name.to_s
26
+ end&.first
27
+ end
28
+
29
+ def declared_field?(model_name, field_name)
30
+ model = self.for(model_name)
31
+ model&.fetch(:attributes, {})&.key?(field_name.to_s) ||
32
+ model&.fetch(:associations, {})&.key?(field_name.to_s)
33
+ end
34
+
35
+ def attribute_type(model_name, attribute)
36
+ self.for(model_name)&.dig(:attributes, attribute.to_s)
37
+ end
38
+
39
+ def association?(model_name, association)
40
+ self.for(model_name)&.fetch(:associations, {})&.key?(association.to_s)
41
+ end
42
+
43
+ def association_metadata(model_name, association)
44
+ self.for(model_name)&.dig(:associations, association.to_s) || {}.with_indifferent_access
45
+ end
46
+
47
+ def association_class_name(model_name, association)
48
+ association_metadata(model_name, association)[:class_name] ||
49
+ association.to_s.singularize.classify
50
+ end
51
+
52
+ def association_collection?(model_name, association)
53
+ association_metadata(model_name, association)[:type].to_s.include?("many")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,5 @@
1
+ module Buezli
2
+ module Api
3
+ VERSION = "0.1.0"
4
+ end
5
+ end
data/lib/buezli/api.rb ADDED
@@ -0,0 +1,18 @@
1
+ require "active_support"
2
+ require "active_support/core_ext/hash/indifferent_access"
3
+ require "active_support/core_ext/object/blank"
4
+ require "active_support/core_ext/string/inflections"
5
+ require "date"
6
+ require "json"
7
+ require "net/http"
8
+ require "pathname"
9
+ require "time"
10
+ require "uri"
11
+
12
+ require_relative "api/version"
13
+ require_relative "api/schema"
14
+ require_relative "api/client/download"
15
+ require_relative "api/client/error"
16
+ require_relative "api/client/list"
17
+ require_relative "api/client/record"
18
+ require_relative "api/client"
@@ -0,0 +1,563 @@
1
+ {
2
+ "version": "2026-07-14",
3
+ "models": {
4
+ "Client": {
5
+ "resource_name": "clients",
6
+ "attributes": {
7
+ "id": "integer",
8
+ "created_at": "datetime",
9
+ "updated_at": "datetime",
10
+ "name": "string",
11
+ "address": "string",
12
+ "currency": "string",
13
+ "locale": "string",
14
+ "archived_at": "datetime"
15
+ },
16
+ "associations": {}
17
+ },
18
+ "Contact": {
19
+ "resource_name": "contacts",
20
+ "attributes": {
21
+ "id": "integer",
22
+ "created_at": "datetime",
23
+ "updated_at": "datetime",
24
+ "client_id": "integer",
25
+ "first_name": "string",
26
+ "last_name": "string",
27
+ "title": "string",
28
+ "email": "string",
29
+ "phone_office": "string",
30
+ "phone_mobile": "string",
31
+ "archived_at": "datetime"
32
+ },
33
+ "associations": {
34
+ "client": {
35
+ "type": "belongs_to",
36
+ "class_name": "Client",
37
+ "inline": false
38
+ }
39
+ }
40
+ },
41
+ "Estimate": {
42
+ "resource_name": "estimates",
43
+ "attributes": {
44
+ "id": "integer",
45
+ "created_at": "datetime",
46
+ "updated_at": "datetime",
47
+ "client_id": "integer",
48
+ "number": "string",
49
+ "status": "string",
50
+ "issue_date": "date",
51
+ "subject": "string",
52
+ "notes": "string",
53
+ "currency": "string",
54
+ "locale": "string",
55
+ "recipient_name": "string",
56
+ "recipient_address": "string",
57
+ "sender_name": "string",
58
+ "sender_address": "string",
59
+ "sender_email": "string",
60
+ "sender_phone": "string",
61
+ "sender_vat_number": "string",
62
+ "discount_rate": "decimal",
63
+ "tax_rate": "decimal",
64
+ "subtotal": "money",
65
+ "discount": "money",
66
+ "tax": "money",
67
+ "tax_breakdown": "array",
68
+ "total": "money",
69
+ "sent_at": "datetime",
70
+ "accepted_at": "datetime",
71
+ "declined_at": "datetime",
72
+ "archived_at": "datetime",
73
+ "pdf": "download"
74
+ },
75
+ "associations": {
76
+ "client": {
77
+ "type": "belongs_to",
78
+ "class_name": "Client",
79
+ "inline": false
80
+ },
81
+ "line_items": {
82
+ "type": "has_many",
83
+ "class_name": "EstimateLineItem",
84
+ "inline": true
85
+ }
86
+ }
87
+ },
88
+ "EstimateLineItem": {
89
+ "resource_name": "estimate_line_items",
90
+ "attributes": {
91
+ "id": "integer",
92
+ "created_at": "datetime",
93
+ "updated_at": "datetime",
94
+ "description": "string",
95
+ "quantity": "decimal",
96
+ "unit_price": "money",
97
+ "amount": "money",
98
+ "tax_rate_override": "decimal",
99
+ "effective_tax_rate": "decimal"
100
+ },
101
+ "associations": {}
102
+ },
103
+ "Expense": {
104
+ "resource_name": "expenses",
105
+ "attributes": {
106
+ "id": "integer",
107
+ "created_at": "datetime",
108
+ "updated_at": "datetime",
109
+ "client_id": "integer",
110
+ "project_id": "integer",
111
+ "expense_category_id": "integer",
112
+ "invoice_id": "integer",
113
+ "spent_on": "date",
114
+ "calculation_type": "string",
115
+ "quantity": "decimal",
116
+ "unit": "string",
117
+ "unit_rate": "money",
118
+ "total": "money",
119
+ "currency": "string",
120
+ "notes": "string",
121
+ "billable": "boolean",
122
+ "billed": "boolean",
123
+ "reimbursed": "boolean",
124
+ "archived_at": "datetime",
125
+ "receipt": "download"
126
+ },
127
+ "associations": {
128
+ "client": {
129
+ "type": "has_one",
130
+ "class_name": "Client",
131
+ "inline": false
132
+ },
133
+ "project": {
134
+ "type": "belongs_to",
135
+ "class_name": "Project",
136
+ "inline": false
137
+ },
138
+ "expense_category": {
139
+ "type": "belongs_to",
140
+ "class_name": "ExpenseCategory",
141
+ "inline": false
142
+ },
143
+ "invoice": {
144
+ "type": "belongs_to",
145
+ "class_name": "Invoice",
146
+ "inline": false
147
+ }
148
+ }
149
+ },
150
+ "ExpenseCategory": {
151
+ "resource_name": "expense_categories",
152
+ "attributes": {
153
+ "id": "integer",
154
+ "created_at": "datetime",
155
+ "updated_at": "datetime",
156
+ "name": "string",
157
+ "archived_at": "datetime"
158
+ },
159
+ "associations": {}
160
+ },
161
+ "Invoice": {
162
+ "resource_name": "invoices",
163
+ "attributes": {
164
+ "id": "integer",
165
+ "created_at": "datetime",
166
+ "updated_at": "datetime",
167
+ "client_id": "integer",
168
+ "source_estimate_id": "integer",
169
+ "number": "string",
170
+ "status": "string",
171
+ "issue_date": "date",
172
+ "due_date": "date",
173
+ "subject": "string",
174
+ "notes": "string",
175
+ "currency": "string",
176
+ "locale": "string",
177
+ "recipient_name": "string",
178
+ "recipient_address": "string",
179
+ "sender_name": "string",
180
+ "sender_address": "string",
181
+ "sender_email": "string",
182
+ "sender_phone": "string",
183
+ "sender_vat_number": "string",
184
+ "sender_iban": "string",
185
+ "payment_reference": "string",
186
+ "tax_rate": "decimal",
187
+ "discount_rate": "decimal",
188
+ "subtotal": "money",
189
+ "discount": "money",
190
+ "tax": "money",
191
+ "tax_breakdown": "array",
192
+ "total": "money",
193
+ "paid": "money",
194
+ "due": "money",
195
+ "sent_at": "datetime",
196
+ "paid_at": "datetime",
197
+ "voided_at": "datetime",
198
+ "void_reason": "string",
199
+ "archived_at": "datetime",
200
+ "recurring_invoice_template_id": "integer",
201
+ "recurring_occurrence_on": "date",
202
+ "pdf": "download"
203
+ },
204
+ "associations": {
205
+ "client": {
206
+ "type": "belongs_to",
207
+ "class_name": "Client",
208
+ "inline": false
209
+ },
210
+ "source_estimate": {
211
+ "type": "belongs_to",
212
+ "class_name": "Estimate",
213
+ "inline": false
214
+ },
215
+ "recurring_invoice_template": {
216
+ "type": "belongs_to",
217
+ "class_name": "RecurringInvoiceTemplate",
218
+ "inline": false
219
+ },
220
+ "line_items": {
221
+ "type": "has_many",
222
+ "class_name": "InvoiceLineItem",
223
+ "inline": true
224
+ },
225
+ "payments": {
226
+ "type": "has_many",
227
+ "class_name": "Payment",
228
+ "inline": true
229
+ }
230
+ }
231
+ },
232
+ "InvoiceLineItem": {
233
+ "resource_name": "invoice_line_items",
234
+ "attributes": {
235
+ "id": "integer",
236
+ "created_at": "datetime",
237
+ "updated_at": "datetime",
238
+ "description": "string",
239
+ "quantity": "decimal",
240
+ "unit_price": "money",
241
+ "amount": "money",
242
+ "tax_rate_override": "decimal",
243
+ "effective_tax_rate": "decimal",
244
+ "project_id": "integer",
245
+ "source_kind": "string"
246
+ },
247
+ "associations": {
248
+ "project": {
249
+ "type": "belongs_to",
250
+ "class_name": "Project",
251
+ "inline": false
252
+ }
253
+ }
254
+ },
255
+ "Payment": {
256
+ "resource_name": "payments",
257
+ "attributes": {
258
+ "id": "integer",
259
+ "created_at": "datetime",
260
+ "updated_at": "datetime",
261
+ "invoice_id": "integer",
262
+ "reversal_of_id": "integer",
263
+ "amount": "money",
264
+ "paid_on": "date",
265
+ "notes": "string"
266
+ },
267
+ "associations": {
268
+ "invoice": {
269
+ "type": "belongs_to",
270
+ "class_name": "Invoice",
271
+ "inline": false
272
+ },
273
+ "reversal_of": {
274
+ "type": "belongs_to",
275
+ "class_name": "Payment",
276
+ "inline": false
277
+ }
278
+ }
279
+ },
280
+ "Project": {
281
+ "resource_name": "projects",
282
+ "attributes": {
283
+ "id": "integer",
284
+ "created_at": "datetime",
285
+ "updated_at": "datetime",
286
+ "client_id": "integer",
287
+ "name": "string",
288
+ "notes": "string",
289
+ "currency": "string",
290
+ "project_type": "string",
291
+ "starts_on": "date",
292
+ "ends_on": "date",
293
+ "archived_at": "datetime"
294
+ },
295
+ "associations": {
296
+ "client": {
297
+ "type": "belongs_to",
298
+ "class_name": "Client",
299
+ "inline": false
300
+ }
301
+ }
302
+ },
303
+ "RecurringInvoiceLineItem": {
304
+ "resource_name": "recurring_invoice_line_items",
305
+ "attributes": {
306
+ "id": "integer",
307
+ "created_at": "datetime",
308
+ "updated_at": "datetime",
309
+ "description": "string",
310
+ "quantity": "decimal",
311
+ "unit_price": "money",
312
+ "amount": "money",
313
+ "tax_rate_override": "decimal",
314
+ "effective_tax_rate": "decimal"
315
+ },
316
+ "associations": {}
317
+ },
318
+ "RecurringInvoiceTemplate": {
319
+ "resource_name": "recurring_invoice_templates",
320
+ "attributes": {
321
+ "id": "integer",
322
+ "created_at": "datetime",
323
+ "updated_at": "datetime",
324
+ "client_id": "integer",
325
+ "name": "string",
326
+ "currency": "string",
327
+ "locale": "string",
328
+ "subject": "string",
329
+ "notes": "string",
330
+ "discount_rate": "decimal",
331
+ "tax_rate": "decimal",
332
+ "interval_unit": "string",
333
+ "interval_count": "integer",
334
+ "starts_on": "date",
335
+ "next_issue_on": "date",
336
+ "ends_on": "date",
337
+ "due_days": "integer",
338
+ "paused_at": "datetime",
339
+ "archived_at": "datetime"
340
+ },
341
+ "associations": {
342
+ "client": {
343
+ "type": "belongs_to",
344
+ "class_name": "Client",
345
+ "inline": false
346
+ },
347
+ "line_items": {
348
+ "type": "has_many",
349
+ "class_name": "RecurringInvoiceLineItem",
350
+ "inline": true
351
+ }
352
+ }
353
+ },
354
+ "Task": {
355
+ "resource_name": "tasks",
356
+ "attributes": {
357
+ "id": "integer",
358
+ "created_at": "datetime",
359
+ "updated_at": "datetime",
360
+ "name": "string",
361
+ "archived_at": "datetime"
362
+ },
363
+ "associations": {}
364
+ },
365
+ "TimeEntry": {
366
+ "resource_name": "time_entries",
367
+ "attributes": {
368
+ "id": "integer",
369
+ "created_at": "datetime",
370
+ "updated_at": "datetime",
371
+ "client_id": "integer",
372
+ "project_id": "integer",
373
+ "task_id": "integer",
374
+ "invoice_id": "integer",
375
+ "spent_on": "date",
376
+ "duration_seconds": "integer",
377
+ "notes": "string",
378
+ "billable": "boolean",
379
+ "billed": "boolean",
380
+ "billable_rate": "money",
381
+ "cost_rate": "money"
382
+ },
383
+ "associations": {
384
+ "client": {
385
+ "type": "has_one",
386
+ "class_name": "Client",
387
+ "inline": false
388
+ },
389
+ "project": {
390
+ "type": "belongs_to",
391
+ "class_name": "Project",
392
+ "inline": false
393
+ },
394
+ "task": {
395
+ "type": "belongs_to",
396
+ "class_name": "Task",
397
+ "inline": false
398
+ },
399
+ "invoice": {
400
+ "type": "belongs_to",
401
+ "class_name": "Invoice",
402
+ "inline": false
403
+ }
404
+ }
405
+ }
406
+ },
407
+ "endpoints": {
408
+ "clients": [
409
+ {
410
+ "action": "index",
411
+ "verb": "GET",
412
+ "path": "/2026-07-14/clients"
413
+ },
414
+ {
415
+ "action": "show",
416
+ "verb": "GET",
417
+ "path": "/2026-07-14/clients/:id"
418
+ }
419
+ ],
420
+ "contacts": [
421
+ {
422
+ "action": "index",
423
+ "verb": "GET",
424
+ "path": "/2026-07-14/contacts"
425
+ },
426
+ {
427
+ "action": "show",
428
+ "verb": "GET",
429
+ "path": "/2026-07-14/contacts/:id"
430
+ }
431
+ ],
432
+ "estimates": [
433
+ {
434
+ "action": "pdf",
435
+ "verb": "GET",
436
+ "path": "/2026-07-14/estimates/:id/pdf"
437
+ },
438
+ {
439
+ "action": "index",
440
+ "verb": "GET",
441
+ "path": "/2026-07-14/estimates"
442
+ },
443
+ {
444
+ "action": "show",
445
+ "verb": "GET",
446
+ "path": "/2026-07-14/estimates/:id"
447
+ }
448
+ ],
449
+ "expense_categories": [
450
+ {
451
+ "action": "index",
452
+ "verb": "GET",
453
+ "path": "/2026-07-14/expense_categories"
454
+ },
455
+ {
456
+ "action": "show",
457
+ "verb": "GET",
458
+ "path": "/2026-07-14/expense_categories/:id"
459
+ }
460
+ ],
461
+ "expenses": [
462
+ {
463
+ "action": "receipt",
464
+ "verb": "GET",
465
+ "path": "/2026-07-14/expenses/:id/receipt"
466
+ },
467
+ {
468
+ "action": "index",
469
+ "verb": "GET",
470
+ "path": "/2026-07-14/expenses"
471
+ },
472
+ {
473
+ "action": "show",
474
+ "verb": "GET",
475
+ "path": "/2026-07-14/expenses/:id"
476
+ }
477
+ ],
478
+ "invoices": [
479
+ {
480
+ "action": "pdf",
481
+ "verb": "GET",
482
+ "path": "/2026-07-14/invoices/:id/pdf"
483
+ },
484
+ {
485
+ "action": "index",
486
+ "verb": "GET",
487
+ "path": "/2026-07-14/invoices"
488
+ },
489
+ {
490
+ "action": "show",
491
+ "verb": "GET",
492
+ "path": "/2026-07-14/invoices/:id"
493
+ }
494
+ ],
495
+ "payments": [
496
+ {
497
+ "action": "index",
498
+ "verb": "GET",
499
+ "path": "/2026-07-14/payments"
500
+ },
501
+ {
502
+ "action": "show",
503
+ "verb": "GET",
504
+ "path": "/2026-07-14/payments/:id"
505
+ }
506
+ ],
507
+ "projects": [
508
+ {
509
+ "action": "index",
510
+ "verb": "GET",
511
+ "path": "/2026-07-14/projects"
512
+ },
513
+ {
514
+ "action": "show",
515
+ "verb": "GET",
516
+ "path": "/2026-07-14/projects/:id"
517
+ }
518
+ ],
519
+ "recurring_invoice_templates": [
520
+ {
521
+ "action": "index",
522
+ "verb": "GET",
523
+ "path": "/2026-07-14/recurring_invoice_templates"
524
+ },
525
+ {
526
+ "action": "show",
527
+ "verb": "GET",
528
+ "path": "/2026-07-14/recurring_invoice_templates/:id"
529
+ }
530
+ ],
531
+ "schema": [
532
+ {
533
+ "action": "show",
534
+ "verb": "GET",
535
+ "path": "/2026-07-14/schema"
536
+ }
537
+ ],
538
+ "tasks": [
539
+ {
540
+ "action": "index",
541
+ "verb": "GET",
542
+ "path": "/2026-07-14/tasks"
543
+ },
544
+ {
545
+ "action": "show",
546
+ "verb": "GET",
547
+ "path": "/2026-07-14/tasks/:id"
548
+ }
549
+ ],
550
+ "time_entries": [
551
+ {
552
+ "action": "index",
553
+ "verb": "GET",
554
+ "path": "/2026-07-14/time_entries"
555
+ },
556
+ {
557
+ "action": "show",
558
+ "verb": "GET",
559
+ "path": "/2026-07-14/time_entries/:id"
560
+ }
561
+ ]
562
+ }
563
+ }
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: buezli-api
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Codegestalt
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activesupport
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.2'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.2'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9'
32
+ - !ruby/object:Gem::Dependency
33
+ name: minitest
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - "~>"
37
+ - !ruby/object:Gem::Version
38
+ version: '5.0'
39
+ type: :development
40
+ prerelease: false
41
+ version_requirements: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - "~>"
44
+ - !ruby/object:Gem::Version
45
+ version: '5.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: rake
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '13.0'
53
+ type: :development
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '13.0'
60
+ description: A read-only Ruby client generated from Buezli's versioned API schema.
61
+ executables: []
62
+ extensions: []
63
+ extra_rdoc_files: []
64
+ files:
65
+ - LICENSE
66
+ - README.md
67
+ - lib/buezli/api.rb
68
+ - lib/buezli/api/client.rb
69
+ - lib/buezli/api/client/download.rb
70
+ - lib/buezli/api/client/error.rb
71
+ - lib/buezli/api/client/list.rb
72
+ - lib/buezli/api/client/record.rb
73
+ - lib/buezli/api/schema.rb
74
+ - lib/buezli/api/version.rb
75
+ - schemas/2026-07-14.json
76
+ homepage: https://buezli.app/api
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ source_code_uri: https://github.com/codegestalt/buezli-api
81
+ documentation_uri: https://buezli.app/api
82
+ bug_tracker_uri: https://github.com/codegestalt/buezli-api/issues
83
+ rubygems_mfa_required: 'true'
84
+ rdoc_options: []
85
+ require_paths:
86
+ - lib
87
+ required_ruby_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '3.2'
92
+ required_rubygems_version: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ requirements: []
98
+ rubygems_version: 4.0.17
99
+ specification_version: 4
100
+ summary: Schema-driven Ruby client for the Buezli API
101
+ test_files: []