thelawin 0.2.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: f3d2a3941184fa5825fee25c391e076ed69bd79c0ec6ed6f506da48ebe12e5c3
4
+ data.tar.gz: d58661f8129b7a75bc4e6e4a68e583cc726051dc8d031d678eb869c6157800ba
5
+ SHA512:
6
+ metadata.gz: 27fbf690097b55c35dbb667efdf8471d63a9e57875135d8ebd79752fbef5753460d154651b5359f0c105bc1bfcc8ceb67446579dbc257ef46d4406f056ed6a75
7
+ data.tar.gz: e5e0a0641e4f936708f48770d6874eb7bdc0d55beaea71fdaefd339d997a4f726a0209266fd0b721e3175bc83eba8e7fc0c83d242b25e648d47b2864b46745ca
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 thelawin.dev
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,418 @@
1
+ # Thelawin Ruby SDK
2
+
3
+ Official Ruby SDK for [thelawin.dev](https://thelawin.dev) - Generate ZUGFeRD/Factur-X/XRechnung/Peppol/FatturaPA compliant invoices with a simple API.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'thelawin', git: 'https://github.com/steviee/thelawin-clients.git', glob: 'ruby/*.gemspec'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ ```bash
16
+ bundle install
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```ruby
22
+ require 'thelawin'
23
+
24
+ client = Thelawin::Client.new(api_key: 'env_sandbox_xxx')
25
+
26
+ result = client.invoice
27
+ .number('2026-001')
28
+ .date('2026-01-15')
29
+ .seller(
30
+ name: 'Acme GmbH',
31
+ vat_id: 'DE123456789',
32
+ street: 'Hauptstraße 1',
33
+ city: 'Berlin',
34
+ postal_code: '10115',
35
+ country: 'DE'
36
+ )
37
+ .buyer(
38
+ name: 'Customer AG',
39
+ city: 'München',
40
+ country: 'DE'
41
+ )
42
+ .add_item(
43
+ description: 'Consulting Services',
44
+ quantity: 8,
45
+ unit: 'HUR',
46
+ unit_price: 150.00,
47
+ vat_rate: 19.0
48
+ )
49
+ .template('minimal')
50
+ .generate
51
+
52
+ if result.success?
53
+ result.save_pdf('./invoices/2026-001.pdf')
54
+ puts "Generated: #{result.filename}"
55
+ puts "Format: #{result.format.format_used}" # => "zugferd"
56
+ else
57
+ result.errors.each do |error|
58
+ puts "#{error[:path]}: #{error[:message]}"
59
+ end
60
+ end
61
+ ```
62
+
63
+ ## Configuration
64
+
65
+ You can configure the client globally:
66
+
67
+ ```ruby
68
+ Thelawin.configure do |config|
69
+ config.api_key = 'env_live_xxx'
70
+ config.environment = :production # :production or :preview
71
+ config.timeout = 30 # optional
72
+ end
73
+
74
+ # Then create clients without passing options
75
+ client = Thelawin::Client.new
76
+
77
+ # Or use the global client directly
78
+ Thelawin.client.invoice.number('2026-001')...
79
+ ```
80
+
81
+ ### Environments
82
+
83
+ | Environment | URL | Description |
84
+ |-------------|-----|-------------|
85
+ | `:production` | `https://api.thelawin.dev` | Production API (default) |
86
+ | `:preview` | `https://api.preview.thelawin.dev:3080` | Preview/staging API |
87
+
88
+ ```ruby
89
+ # Use preview environment globally
90
+ Thelawin.configure do |config|
91
+ config.api_key = 'env_sandbox_xxx'
92
+ config.environment = :preview
93
+ end
94
+
95
+ # Or per-client
96
+ client = Thelawin::Client.new(
97
+ api_key: 'env_sandbox_xxx',
98
+ environment: :preview
99
+ )
100
+
101
+ # Check environment
102
+ client.preview? # => true
103
+ client.production? # => false
104
+
105
+ # Custom URL (overrides environment)
106
+ client = Thelawin::Client.new(
107
+ api_key: 'env_sandbox_xxx',
108
+ base_url: 'http://localhost:8080'
109
+ )
110
+ ```
111
+
112
+ ## Supported Formats
113
+
114
+ | Format | Description | Output |
115
+ |--------|-------------|--------|
116
+ | `auto` | Auto-detect based on countries (default) | PDF or XML |
117
+ | `zugferd` | ZUGFeRD 2.3 (Germany/EU) | PDF/A-3 + CII XML |
118
+ | `facturx` | Factur-X 1.0 (France) | PDF/A-3 + CII XML |
119
+ | `xrechnung` | XRechnung 3.0 (German B2G) | PDF/A-3 + UBL XML |
120
+ | `pdf` | Plain PDF without XML | PDF |
121
+ | `ubl` | UBL 2.1 Invoice | XML only |
122
+ | `cii` | UN/CEFACT CII | XML only |
123
+ | `peppol` | Peppol BIS Billing 3.0 | XML only |
124
+ | `fatturapa` | FatturaPA 1.2.1 (Italy) | XML only |
125
+
126
+ ## API Reference
127
+
128
+ ### InvoiceBuilder
129
+
130
+ Fluent builder for creating invoices:
131
+
132
+ ```ruby
133
+ client.invoice
134
+ # Required fields
135
+ .number(value) # Invoice number
136
+ .date(value) # Date string or Date object
137
+ .seller(name:, **opts) # Seller info
138
+ .buyer(name:, **opts) # Buyer info
139
+ .add_item(description:, quantity:, unit_price:, **opts)
140
+
141
+ # Format & Profile
142
+ .format('zugferd') # Output format (default: 'auto')
143
+ .profile('en16931') # Profile level (default: 'en16931')
144
+
145
+ # Optional invoice fields
146
+ .due_date(value) # Payment due date
147
+ .currency('EUR') # Currency code (default: 'EUR')
148
+ .notes('Thank you!') # Invoice notes/comments
149
+ .payment(iban:, bic:, terms:) # Payment information
150
+
151
+ # Format-specific fields
152
+ .leitweg_id('04011000-12345-67') # XRechnung: German B2G routing
153
+ .buyer_reference('PO-12345') # Peppol: Purchase order reference
154
+ .tipo_documento('TD01') # FatturaPA: Document type
155
+
156
+ # Customization
157
+ .template('minimal') # 'minimal', 'classic', 'compact'
158
+ .locale('de') # 'de', 'en', 'fr', 'es', 'it'
159
+ .logo_file('./logo.png', width_mm: 30)
160
+ .footer_text('Thank you!')
161
+ .accent_color('#8b5cf6')
162
+
163
+ # Execute
164
+ .generate # Generate invoice
165
+ .validate # Dry-run validation only
166
+ ```
167
+
168
+ ### Party (seller/buyer)
169
+
170
+ ```ruby
171
+ Thelawin::Party.new(
172
+ name: 'Company Name', # Required
173
+ street: 'Street Address',
174
+ city: 'City',
175
+ postal_code: '12345',
176
+ country: 'DE', # ISO 3166-1 alpha-2
177
+ vat_id: 'DE123456789',
178
+ email: 'email@example.com',
179
+ phone: '+49 30 12345678',
180
+ # Peppol-specific
181
+ peppol_id: '0088:1234567890123', # EAS:ID format
182
+ # FatturaPA-specific (Italy)
183
+ codice_fiscale: 'RSSMRA80A01H501U',
184
+ codice_destinatario: 'ABCDEFG', # SDI code (7 chars)
185
+ pec: 'email@pec.it' # Certified email
186
+ )
187
+ ```
188
+
189
+ ### LineItem
190
+
191
+ ```ruby
192
+ Thelawin::LineItem.new(
193
+ description: 'Service', # Required
194
+ quantity: 8.0, # Required
195
+ unit_price: 150.00, # Required
196
+ unit: 'HUR', # UN/ECE Rec 20 code (default: 'C62')
197
+ vat_rate: 19.0, # Default: 19.0
198
+ natura: 'N2.2' # FatturaPA: VAT exemption code
199
+ )
200
+ ```
201
+
202
+ ### Common Unit Codes
203
+
204
+ | Code | Description |
205
+ |------|-------------|
206
+ | `C62` | Piece (default) |
207
+ | `HUR` | Hour |
208
+ | `DAY` | Day |
209
+ | `MON` | Month |
210
+ | `KGM` | Kilogram |
211
+ | `MTR` | Meter |
212
+ | `LTR` | Liter |
213
+
214
+ ### Result Handling
215
+
216
+ ```ruby
217
+ result = client.invoice.generate
218
+
219
+ if result.success?
220
+ puts result.filename # 'invoice-2026-001.pdf' or '.xml'
221
+ puts result.format.format_used # 'zugferd', 'fatturapa', etc.
222
+ puts result.format.profile # 'EN16931'
223
+ puts result.format.version # '2.3'
224
+
225
+ # Check output type
226
+ if result.xml_only?
227
+ result.save('./invoice.xml')
228
+ else
229
+ result.save_pdf('./invoice.pdf')
230
+ end
231
+
232
+ # Legal warnings
233
+ result.warnings.each do |warning|
234
+ puts "#{warning.code}: #{warning.message}"
235
+ puts "Legal basis: #{warning.legal_basis}"
236
+ end
237
+ else
238
+ result.errors.each do |error|
239
+ puts "#{error[:path]}: #{error[:message]}"
240
+ end
241
+ end
242
+ ```
243
+
244
+ ### Pre-Validation (Dry-Run)
245
+
246
+ Validate invoice data without generating PDF:
247
+
248
+ ```ruby
249
+ result = client.invoice
250
+ .number('2026-001')
251
+ .date('2026-01-15')
252
+ .seller(name: 'Acme', country: 'DE')
253
+ .buyer(name: 'Customer', country: 'IT')
254
+ .add_item(description: 'Service', quantity: 1, unit_price: 100)
255
+ .format('fatturapa')
256
+ .validate # Dry-run validation
257
+
258
+ if result.valid?
259
+ puts "Valid! Would generate: #{result.format.format_used}"
260
+ else
261
+ result.errors.each { |e| puts e }
262
+ end
263
+ ```
264
+
265
+ ### Account Info
266
+
267
+ ```ruby
268
+ account = client.account
269
+ puts account.plan # => "starter"
270
+ puts account.remaining # => 450
271
+ puts account.overage_count # => 0
272
+ ```
273
+
274
+ ## Error Handling
275
+
276
+ ```ruby
277
+ begin
278
+ result = client.invoice.number('2026-001').generate
279
+
280
+ unless result.success?
281
+ # Validation errors (422)
282
+ result.errors.each do |error|
283
+ puts "#{error[:path]}: #{error[:message]}"
284
+ end
285
+ end
286
+ rescue Thelawin::QuotaExceededError
287
+ puts 'Quota exceeded, upgrade your plan'
288
+ rescue Thelawin::NetworkError => e
289
+ puts "Network error: #{e.message}"
290
+ rescue Thelawin::ApiError => e
291
+ puts "API error #{e.status_code}: #{e.message}"
292
+ end
293
+ ```
294
+
295
+ ## Format-Specific Examples
296
+
297
+ ### XRechnung (German B2G)
298
+
299
+ ```ruby
300
+ result = client.invoice
301
+ .format('xrechnung')
302
+ .leitweg_id('04011000-12345-67') # Required for B2G
303
+ .seller(
304
+ name: 'Acme GmbH',
305
+ vat_id: 'DE123456789',
306
+ email: 'invoice@acme.de', # Required for XRechnung
307
+ street: 'Hauptstraße 1',
308
+ city: 'Berlin',
309
+ postal_code: '10115',
310
+ country: 'DE'
311
+ )
312
+ # ... rest of invoice
313
+ .generate
314
+ ```
315
+
316
+ ### Peppol
317
+
318
+ ```ruby
319
+ result = client.invoice
320
+ .format('peppol')
321
+ .buyer_reference('PO-12345')
322
+ .seller(
323
+ name: 'Acme Ltd',
324
+ vat_id: 'GB123456789',
325
+ peppol_id: '0088:1234567890123',
326
+ # ...
327
+ )
328
+ .buyer(
329
+ name: 'Customer BV',
330
+ peppol_id: '0106:NL123456789B01',
331
+ # ...
332
+ )
333
+ .generate
334
+ ```
335
+
336
+ ### FatturaPA (Italy)
337
+
338
+ ```ruby
339
+ result = client.invoice
340
+ .format('fatturapa')
341
+ .tipo_documento('TD01') # TD01=invoice, TD04=credit note
342
+ .seller(
343
+ name: 'Acme S.r.l.',
344
+ vat_id: 'IT12345678901',
345
+ codice_fiscale: '12345678901',
346
+ street: 'Via Roma 1',
347
+ city: 'Milano',
348
+ postal_code: '20100',
349
+ country: 'IT'
350
+ )
351
+ .buyer(
352
+ name: 'Cliente S.p.A.',
353
+ vat_id: 'IT98765432109',
354
+ codice_destinatario: 'ABCDEFG', # SDI code
355
+ # OR: pec: 'cliente@pec.it'
356
+ city: 'Roma',
357
+ country: 'IT'
358
+ )
359
+ .add_item(
360
+ description: 'Consulenza',
361
+ quantity: 10,
362
+ unit_price: 100,
363
+ vat_rate: 22.0
364
+ )
365
+ .generate
366
+
367
+ # FatturaPA returns XML only
368
+ result.save('./fattura.xml')
369
+ ```
370
+
371
+ ## Rails Integration
372
+
373
+ ```ruby
374
+ # config/initializers/thelawin.rb
375
+ Thelawin.configure do |config|
376
+ config.api_key = Rails.application.credentials.thelawin_api_key
377
+ config.environment = Rails.env.production? ? :production : :preview
378
+ end
379
+
380
+ # In your service
381
+ class InvoiceService
382
+ def generate_invoice(order)
383
+ Thelawin.client.invoice
384
+ .number(order.invoice_number)
385
+ .date(order.created_at.to_date)
386
+ .seller(company_details)
387
+ .buyer(customer_party(order.customer))
388
+ .items(order.line_items.map { |li| line_item_attrs(li) })
389
+ .generate
390
+ end
391
+
392
+ private
393
+
394
+ def company_details
395
+ Thelawin::Party.new(
396
+ name: 'My Company',
397
+ vat_id: ENV['COMPANY_VAT_ID'],
398
+ street: '123 Main St',
399
+ city: 'Berlin',
400
+ postal_code: '10115',
401
+ country: 'DE'
402
+ )
403
+ end
404
+ end
405
+ ```
406
+
407
+ ## Development
408
+
409
+ After checking out the repo, run `bundle install` to install dependencies. Then, run `rspec` to run the tests.
410
+
411
+ ```bash
412
+ bundle install
413
+ bundle exec rspec
414
+ ```
415
+
416
+ ## License
417
+
418
+ MIT
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/retry"
5
+ require "json"
6
+ require "fileutils"
7
+
8
+ module Thelawin
9
+ # Main client for interacting with the thelawin.dev API
10
+ class Client
11
+ attr_reader :api_key, :base_url, :timeout, :environment
12
+
13
+ # Create a new Thelawin Client
14
+ # @param api_key [String] Your API key (env_sandbox_* or env_live_*)
15
+ # @param environment [Symbol] :production or :preview (default: from config or :production)
16
+ # @param base_url [String] Custom API base URL (overrides environment)
17
+ # @param timeout [Integer] Request timeout in seconds (default: 30)
18
+ def initialize(api_key: nil, environment: nil, base_url: nil, timeout: nil)
19
+ @api_key = api_key || Thelawin.configuration.api_key
20
+ @environment = environment || Thelawin.configuration.environment
21
+ @timeout = timeout || Thelawin.configuration.timeout
22
+
23
+ # Use custom base_url if provided, otherwise use environment default
24
+ @base_url = base_url || Thelawin::ENVIRONMENTS[@environment]
25
+
26
+ raise ArgumentError, "API key is required" if @api_key.nil? || @api_key.empty?
27
+ end
28
+
29
+ # Check if using preview environment
30
+ # @return [Boolean]
31
+ def preview?
32
+ @environment == :preview
33
+ end
34
+
35
+ # Check if using production environment
36
+ # @return [Boolean]
37
+ def production?
38
+ @environment == :production
39
+ end
40
+
41
+ # Backwards compatibility alias
42
+ alias api_url base_url
43
+
44
+ # Create a new invoice builder with fluent API
45
+ # @return [InvoiceBuilder]
46
+ def invoice
47
+ InvoiceBuilder.new(self)
48
+ end
49
+
50
+ # Pre-validate invoice data without generating PDF (dry-run)
51
+ # @param request [Hash] Invoice request data
52
+ # @return [DryRunResult]
53
+ def validate(request)
54
+ response = connection.post("/v1/validate") do |req|
55
+ req.body = request.to_json
56
+ end
57
+
58
+ data = handle_response(response)
59
+ DryRunResult.new(data)
60
+ end
61
+
62
+ # Get account information (quota, plan, etc.)
63
+ # @return [AccountInfo]
64
+ def account
65
+ response = connection.get("/v1/account")
66
+ data = handle_response(response)
67
+ AccountInfo.new(data)
68
+ end
69
+
70
+ private
71
+
72
+ def generate_invoice_internal(request)
73
+ response = connection.post("/v1/generate") do |req|
74
+ req.body = request.to_json
75
+ end
76
+
77
+ handle_generate_response(response)
78
+ rescue Faraday::TimeoutError
79
+ raise NetworkError, "Request timeout"
80
+ rescue Faraday::ConnectionFailed => e
81
+ raise NetworkError.new("Connection failed", e)
82
+ end
83
+
84
+ def validate_invoice_internal(request)
85
+ response = connection.post("/v1/validate") do |req|
86
+ req.body = request.to_json
87
+ end
88
+
89
+ handle_validate_response(response)
90
+ rescue Faraday::TimeoutError
91
+ raise NetworkError, "Request timeout"
92
+ rescue Faraday::ConnectionFailed => e
93
+ raise NetworkError.new("Connection failed", e)
94
+ end
95
+
96
+ def handle_generate_response(response)
97
+ case response.status
98
+ when 200
99
+ data = JSON.parse(response.body)
100
+ InvoiceSuccess.new(
101
+ pdf_base64: data["pdfBase64"],
102
+ filename: data["filename"],
103
+ format: FormatInfo.new(data["format"]),
104
+ account: data["account"] ? AccountInfo.new(data["account"]) : nil
105
+ )
106
+ when 402
107
+ data = JSON.parse(response.body)
108
+ raise QuotaExceededError, data["message"] || "Quota exceeded"
109
+ when 422
110
+ data = JSON.parse(response.body)
111
+ if data["details"]
112
+ InvoiceFailure.new(errors: data["details"].map { |e| e.transform_keys(&:to_sym) })
113
+ else
114
+ raise ApiError.new(data["message"] || data["error"], response.status, data["error"])
115
+ end
116
+ else
117
+ data = JSON.parse(response.body) rescue { "error" => "unknown_error", "message" => "HTTP #{response.status}" }
118
+ raise ApiError.new(data["message"] || data["error"], response.status, data["error"])
119
+ end
120
+ end
121
+
122
+ def handle_validate_response(response)
123
+ case response.status
124
+ when 200
125
+ data = JSON.parse(response.body)
126
+ DryRunResult.new(data)
127
+ when 422
128
+ data = JSON.parse(response.body)
129
+ if data["details"]
130
+ InvoiceFailure.new(errors: data["details"].map { |e| e.transform_keys(&:to_sym) })
131
+ else
132
+ DryRunResult.new(data)
133
+ end
134
+ else
135
+ data = JSON.parse(response.body) rescue { "error" => "unknown_error", "message" => "HTTP #{response.status}" }
136
+ raise ApiError.new(data["message"] || data["error"], response.status, data["error"])
137
+ end
138
+ end
139
+
140
+ def handle_response(response)
141
+ unless response.success?
142
+ data = JSON.parse(response.body) rescue { "error" => "unknown_error" }
143
+ raise ApiError.new(data["message"] || data["error"], response.status, data["error"])
144
+ end
145
+
146
+ JSON.parse(response.body)
147
+ end
148
+
149
+ def connection
150
+ @connection ||= Faraday.new(url: @base_url) do |f|
151
+ f.request :retry, max: 2, interval: 0.5
152
+ f.headers["Content-Type"] = "application/json"
153
+ f.headers["X-API-Key"] = @api_key
154
+ f.options.timeout = @timeout
155
+ f.options.open_timeout = 10
156
+ f.adapter Faraday.default_adapter
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Thelawin
4
+ # Base error class for all Thelawin SDK errors
5
+ class Error < StandardError; end
6
+
7
+ # Error raised when the API returns validation errors
8
+ class ValidationError < Error
9
+ attr_reader :errors, :status_code
10
+
11
+ def initialize(errors, status_code = 422)
12
+ @errors = errors
13
+ @status_code = status_code
14
+ message = errors.map { |e| "#{e[:path]}: #{e[:message]}" }.join("; ")
15
+ super("Validation failed: #{message}")
16
+ end
17
+
18
+ # Get a user-friendly error message
19
+ # @return [String]
20
+ def to_user_message
21
+ @errors.map { |e| "- #{e[:path]}: #{e[:message]}" }.join("\n")
22
+ end
23
+ end
24
+
25
+ # Error raised when the API returns an HTTP error
26
+ class ApiError < Error
27
+ attr_reader :status_code, :code
28
+
29
+ def initialize(message, status_code, code = nil)
30
+ @status_code = status_code
31
+ @code = code
32
+ super(message)
33
+ end
34
+ end
35
+
36
+ # Error raised when a network request fails
37
+ class NetworkError < Error
38
+ attr_reader :cause
39
+
40
+ def initialize(message, cause = nil)
41
+ @cause = cause
42
+ super(message)
43
+ end
44
+ end
45
+
46
+ # Error raised when quota is exceeded
47
+ class QuotaExceededError < ApiError
48
+ def initialize(message)
49
+ super(message, 402, "quota_exceeded")
50
+ end
51
+ end
52
+ end