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 +7 -0
- data/LICENSE +21 -0
- data/README.md +418 -0
- data/lib/thelawin/client.rb +160 -0
- data/lib/thelawin/errors.rb +52 -0
- data/lib/thelawin/invoice.rb +352 -0
- data/lib/thelawin/types.rb +211 -0
- data/lib/thelawin/version.rb +5 -0
- data/lib/thelawin.rb +112 -0
- metadata +137 -0
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
|