gobl 0.1.2

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.
Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/data/tax/ES.json +841 -0
  3. data/data/tax/FR.json +47 -0
  4. data/data/tax/GB.json +56 -0
  5. data/data/tax/NL.json +60 -0
  6. data/home/cavalle/workspace/invopop/gobl.ruby/gobl.gemspec +30 -0
  7. data/lib/extensions.rb +12 -0
  8. data/lib/gobl/bill/advances.rb +90 -0
  9. data/lib/gobl/bill/charge.rb +121 -0
  10. data/lib/gobl/bill/delivery.rb +79 -0
  11. data/lib/gobl/bill/discount.rb +121 -0
  12. data/lib/gobl/bill/exchange_rates.rb +90 -0
  13. data/lib/gobl/bill/invoice.rb +209 -0
  14. data/lib/gobl/bill/invoice_type.rb +163 -0
  15. data/lib/gobl/bill/line.rb +121 -0
  16. data/lib/gobl/bill/line_charge.rb +79 -0
  17. data/lib/gobl/bill/line_discount.rb +79 -0
  18. data/lib/gobl/bill/ordering.rb +58 -0
  19. data/lib/gobl/bill/outlay.rb +107 -0
  20. data/lib/gobl/bill/payment.rb +79 -0
  21. data/lib/gobl/bill/preceding.rb +114 -0
  22. data/lib/gobl/bill/scheme_keys.rb +90 -0
  23. data/lib/gobl/bill/tax.rb +72 -0
  24. data/lib/gobl/bill/totals.rb +135 -0
  25. data/lib/gobl/cal/date.rb +106 -0
  26. data/lib/gobl/cal/period.rb +63 -0
  27. data/lib/gobl/config.rb +14 -0
  28. data/lib/gobl/currency/code.rb +322 -0
  29. data/lib/gobl/currency/exchange_rate.rb +65 -0
  30. data/lib/gobl/document.rb +70 -0
  31. data/lib/gobl/dsig/digest.rb +65 -0
  32. data/lib/gobl/dsig/signature.rb +106 -0
  33. data/lib/gobl/envelope.rb +77 -0
  34. data/lib/gobl/header.rb +98 -0
  35. data/lib/gobl/i18n/string.rb +72 -0
  36. data/lib/gobl/id.rb +80 -0
  37. data/lib/gobl/l10n/code.rb +106 -0
  38. data/lib/gobl/l10n/country_code.rb +405 -0
  39. data/lib/gobl/note/message.rb +72 -0
  40. data/lib/gobl/num/amount.rb +248 -0
  41. data/lib/gobl/num/percentage.rb +84 -0
  42. data/lib/gobl/operations/service_error.rb +10 -0
  43. data/lib/gobl/operations/validation_result.rb +46 -0
  44. data/lib/gobl/operations.rb +170 -0
  45. data/lib/gobl/org/address.rb +156 -0
  46. data/lib/gobl/org/code.rb +106 -0
  47. data/lib/gobl/org/coordinates.rb +79 -0
  48. data/lib/gobl/org/email.rb +79 -0
  49. data/lib/gobl/org/inbox.rb +86 -0
  50. data/lib/gobl/org/item.rb +121 -0
  51. data/lib/gobl/org/item_code.rb +65 -0
  52. data/lib/gobl/org/key.rb +106 -0
  53. data/lib/gobl/org/meta.rb +72 -0
  54. data/lib/gobl/org/name.rb +114 -0
  55. data/lib/gobl/org/note.rb +79 -0
  56. data/lib/gobl/org/note_key.rb +181 -0
  57. data/lib/gobl/org/party.rb +135 -0
  58. data/lib/gobl/org/person.rb +100 -0
  59. data/lib/gobl/org/registration.rb +99 -0
  60. data/lib/gobl/org/source_key.rb +161 -0
  61. data/lib/gobl/org/tax_identity.rb +93 -0
  62. data/lib/gobl/org/telephone.rb +72 -0
  63. data/lib/gobl/org/unit.rb +204 -0
  64. data/lib/gobl/pay/advance.rb +107 -0
  65. data/lib/gobl/pay/card.rb +65 -0
  66. data/lib/gobl/pay/credit_transfer.rb +86 -0
  67. data/lib/gobl/pay/direct_debit.rb +72 -0
  68. data/lib/gobl/pay/due_date.rb +86 -0
  69. data/lib/gobl/pay/instructions.rb +114 -0
  70. data/lib/gobl/pay/method_key.rb +163 -0
  71. data/lib/gobl/pay/online.rb +65 -0
  72. data/lib/gobl/pay/term_key.rb +166 -0
  73. data/lib/gobl/pay/terms.rb +79 -0
  74. data/lib/gobl/stamp.rb +63 -0
  75. data/lib/gobl/struct.rb +48 -0
  76. data/lib/gobl/tax/category.rb +83 -0
  77. data/lib/gobl/tax/category_total.rb +87 -0
  78. data/lib/gobl/tax/combo.rb +79 -0
  79. data/lib/gobl/tax/localities.rb +90 -0
  80. data/lib/gobl/tax/locality.rb +72 -0
  81. data/lib/gobl/tax/rate.rb +77 -0
  82. data/lib/gobl/tax/rate_total.rb +82 -0
  83. data/lib/gobl/tax/rate_total_surcharge.rb +63 -0
  84. data/lib/gobl/tax/rate_value.rb +79 -0
  85. data/lib/gobl/tax/region.rb +100 -0
  86. data/lib/gobl/tax/scheme.rb +86 -0
  87. data/lib/gobl/tax/schemes.rb +90 -0
  88. data/lib/gobl/tax/set.rb +90 -0
  89. data/lib/gobl/tax/total.rb +65 -0
  90. data/lib/gobl/types.rb +17 -0
  91. data/lib/gobl/uuid/uuid.rb +106 -0
  92. data/lib/gobl/version.rb +5 -0
  93. data/lib/gobl.rb +41 -0
  94. data/lib/gobl_extensions/document_helper.rb +45 -0
  95. data/lib/gobl_extensions/envelope_helper.rb +15 -0
  96. data/lib/gobl_extensions/i18n/value_keys_helper.rb +27 -0
  97. data/lib/gobl_extensions/tax/region_helper.rb +41 -0
  98. metadata +225 -0
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GOBL
4
+ module Num
5
+ # Represents a numeric quantity with optional decimal places that determine accuracy
6
+ class Amount
7
+ # The integer value of the amount
8
+ attr_reader :value
9
+
10
+ # The exponent, or number of significant figures of the amount
11
+ attr_reader :exp
12
+
13
+ # Creates a new {Amount} from the given data object
14
+ #
15
+ # @param data [Hash, String, #to_s, Amount] the data object. Supported types:
16
+ #
17
+ # * A `Hash` with a `:value` key and, optionally, an `:exp` one,
18
+ # * A `String` or any other object that can be coerced into one via `#to_s`,
19
+ # * Another {Amount}
20
+ def initialize(data)
21
+ if data.is_a?(String)
22
+ parse(data)
23
+ elsif data.is_a?(Hash)
24
+ @value = data[:value]
25
+ @exp = data[:exp] || 0
26
+ elsif data.is_a?(self.class)
27
+ @value = data.value
28
+ @exp = data.exp
29
+ elsif data.respond_to?(:to_s)
30
+ parse(data.to_s)
31
+ else
32
+ raise 'Unsupported input amount'
33
+ end
34
+ end
35
+
36
+ # Creates a new {Amount} from a GOBL value
37
+ #
38
+ # @param data [Hash] a GOBL value
39
+ #
40
+ # @return [Amount] the object created from the given data
41
+ def self.from_gobl!(data)
42
+ new(data)
43
+ end
44
+
45
+ # Deserializes an {Amount} from a JSON string
46
+ #
47
+ # @param json [String] a JSON string
48
+ #
49
+ # @return [Amount] the deserialized {Amount}
50
+ def self.from_json!(json)
51
+ from_gobl!(JSON.parse(json))
52
+ end
53
+
54
+ # Returns a GOBL value representing the current amount
55
+ #
56
+ # @return [String] the GOBL value that represents the current amount
57
+ def to_gobl
58
+ to_s
59
+ end
60
+
61
+ # Serializes a GOBL struct into a JSON string
62
+ #
63
+ # @param options [#to_h] a Hash-like object to pass to `JSON.generate`
64
+ #
65
+ # @return [GOBL::Struct] the JSON string representing the GOBL struct
66
+ def to_json(options = nil)
67
+ JSON.generate(to_gobl, options)
68
+ end
69
+
70
+ # Returns the string representation of the current amount
71
+ #
72
+ # @return [String] the string representation of the current amount
73
+ #
74
+ # @see #as_s
75
+ def to_s
76
+ as_s
77
+ end
78
+
79
+ # Returns the string representation of the current amount
80
+ #
81
+ # @return [String] the string representation of the current amount
82
+ def as_s
83
+ return value.to_s if exp.zero?
84
+ raise 'exponent too high' if exp > 100
85
+
86
+ p = 10**exp
87
+ v1 = value / p
88
+ v2 = value - (v1 * p)
89
+ v2 = -v2 if v2.negative?
90
+ format('%d.%0*d', v1, exp, v2)
91
+ end
92
+
93
+ # Rescales each {Amount} in the pair ensuring both have the same exponent
94
+ #
95
+ # @param a1 [Amount] the first amount of the pair
96
+ # @param a2 [Amount] the second amount of the pair
97
+ #
98
+ # @return [Array(Amount, Amount)] the rescaled pair of amounts
99
+ def self.rescale_pair(a1, a2)
100
+ exp = a1.exp
101
+ exp = a2.exp if a2.exp > exp
102
+ [a1.rescale(exp), a2.rescale(exp)]
103
+ end
104
+
105
+ # Compares the current amount with another one
106
+ #
107
+ # @param other [Amount] the {Amount} to compare with
108
+ #
109
+ # @return [Integer] the result of the comparison:
110
+ #
111
+ # * `-1` if the current amount is lesser than the given one
112
+ # * `1` if current amount is greater than the given one
113
+ # * `0` if both amounts are equal
114
+ def compare(other)
115
+ a1, a2 = self.class.rescale_pair(self, other)
116
+ if a1.value < a2.value
117
+ -1
118
+ elsif a1.value > a2.value
119
+ 1
120
+ else
121
+ 0 # same
122
+ end
123
+ end
124
+
125
+ # Returns whether the current amount is equal to another one
126
+ #
127
+ # @param other [Amount] the {Amount} to compare with
128
+ #
129
+ # @return [Boolean] whether the two amounts are equal (`true`) or not (`false`)
130
+ def ==(other)
131
+ compare(other).zero?
132
+ end
133
+
134
+ # Changes the exponent of the {Amount} multipling or dividing its value as
135
+ # necessary. A lower exponent implies loosing accuracy.
136
+ #
137
+ # @param e [Integer] the new exponent
138
+ #
139
+ # @return [Amount] the rescaled {Amount}
140
+ def rescale(e)
141
+ if exp > e
142
+ # divide
143
+ x = exp - e
144
+ v = (value.to_f / (10**x)).round
145
+ self.class.new(value: v, exp: e)
146
+ elsif exp < e
147
+ # multiply
148
+ x = e - exp
149
+ v = value * (10**x)
150
+ self.class.new(value: v, exp: e)
151
+ else
152
+ # nothing
153
+ self
154
+ end
155
+ end
156
+
157
+ # Returns whether the current amount is equal to zero
158
+ #
159
+ # @return [Boolean] `true` if the current amount is equal to zero, `false`otherwise
160
+ def zero?
161
+ value.zero?
162
+ end
163
+
164
+ # Adds the current amount to a given one
165
+ #
166
+ # @param a2 [Amount] the amount to add
167
+ #
168
+ # @return [Amount] the amount resulted from the operation
169
+ def add(a2)
170
+ a2 = a2.rescale(exp)
171
+ self.class.new(value: value + a2.value, exp: exp)
172
+ end
173
+
174
+ # Subtracts a given {Amount} from the current one
175
+ #
176
+ # @param a2 [Amount] the amount to subtract
177
+ #
178
+ # @return [Amount] the amount resulted from the operation
179
+ def subtract(a2)
180
+ a2 = a2.rescale(exp)
181
+ self.class.new(value: value - a2.value, exp: exp)
182
+ end
183
+
184
+ # Multiplies a given {Amount} with the current one
185
+ #
186
+ # @param a2 [Amount] the amount to multiply with
187
+ #
188
+ # @return [Amount] the amount resulted from the operation
189
+ def multiply(a2)
190
+ v = (value * a2.value) / (10**a2.exp)
191
+ self.class.new(value: v, exp: exp)
192
+ end
193
+
194
+ # Divides the current amount by the given one
195
+ #
196
+ # @param a2 [Amount] the amount to divide by
197
+ #
198
+ # @return [Amount] the amount resulted from the operation
199
+ def divide(a2)
200
+ v = (value.to_f * (10**a2.exp)) / a2.value.to_f
201
+ self.class.new(value: v.round, exp: exp)
202
+ end
203
+
204
+ # Splits the current amount into equal `x` parts providing a second amount with
205
+ # the remainder. This is like {#divide}, but will correctly account for rounding
206
+ # errors.
207
+ #
208
+ # @param x [Integer] the number of parts to divide the amount into
209
+ #
210
+ # @return [Array(Amount, Amount)] the split amount and the remider amount
211
+ def split(x)
212
+ a2 = divide(self.class.new(value: x, exp: 0))
213
+ a3 = a2.multiply(self.class.new(value: x - 1, exp: 0))
214
+ a3 = subtract(a3)
215
+ [a2, a3]
216
+ end
217
+
218
+ # Inverts the sign of the current amount
219
+ #
220
+ # @return [Amount] the amount with the opposite sign
221
+ def invert
222
+ self.class.new(value: -value, exp: exp)
223
+ end
224
+
225
+ protected
226
+
227
+ # Extract the value and exponent from the string.
228
+ # Implementation copied from base GOBL library.
229
+ def parse(str)
230
+ x = str.split('.')
231
+ raise "invalid amount '#{str}'" if x.length > 2 # abort
232
+
233
+ v = x[0].to_i
234
+ e = 0
235
+
236
+ if x[1]
237
+ v2 = x[1].to_i
238
+ e = x[1].length
239
+ v *= 10**e
240
+ v += v2
241
+ end
242
+
243
+ @value = v
244
+ @exp = e
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GOBL
4
+ module Num
5
+ Factor1 = Amount.new(value: 1) # @api private
6
+ Factor100 = Amount.new(value: 100) # @api private
7
+
8
+ # Similar to {GOBL::Num::Amount}, but specialized in representing percentages
9
+ class Percentage < Amount
10
+ # Returns a string representation of the percentage with the percentage symbol
11
+ #
12
+ # @return [String] the string representing the percentage
13
+ def to_s
14
+ "#{to_s_without_symbol}%"
15
+ end
16
+
17
+ # Returns a string representation of the percentage with_out the percentage symbol
18
+ #
19
+ # @return [String] the string representing the percentage
20
+ def to_s_without_symbol
21
+ e = exp - 2
22
+ e = 0 if e.negative?
23
+ p = multiply(Factor100).rescale(e)
24
+ p.as_s
25
+ end
26
+
27
+ # Calculates the "percent of" a given amount
28
+ #
29
+ # @param a [GOBL::Num::Amount] the amount to calculate the percent of
30
+ #
31
+ # @return [GOBL::Num::Amount] the calculated amount
32
+ def of(a)
33
+ a.multiply(self)
34
+ end
35
+
36
+ # Calculates what "percent from" the given amount would result assuming the rate has
37
+ # already been applied. Put otherwise, for a given amount that was increased by the
38
+ # current percentage, this method returns the amount of that increment.
39
+ #
40
+ # @example
41
+ # original_amount = GOBL::Num::Amount.new("200")
42
+ # percentage = GOBL::Num::Percentage.new("25%")
43
+ # increment = percentage.of(original_amount) #=> 50
44
+ #
45
+ # increased_amount = original_amount.add(increment) #=> 250
46
+ # percentage.from(increased_amount) #=> 50
47
+ #
48
+ # @param a [GOBL::Num::Amount] the amount to calculate the percent from
49
+ #
50
+ # @return [GOBL::Num::Amount] the calculated amount
51
+ def from(a)
52
+ x = a.divide(factor)
53
+ a.subtract(x)
54
+ end
55
+
56
+ # Returns the percentage amount as a factor, adding 1 to the rate
57
+ #
58
+ # @return [GOBL::Num::Amount] the factor
59
+ def factor
60
+ Amount.new(value: value, exp: exp).add(Factor1)
61
+ end
62
+
63
+ protected
64
+
65
+ def parse(data)
66
+ return if data.length.zero?
67
+
68
+ rescale = false
69
+ if data[-1] == '%'
70
+ data = data.chomp('%')
71
+ rescale = true
72
+ end
73
+
74
+ super(data)
75
+
76
+ if rescale
77
+ p = self.rescale(exp + 2).divide(Factor100)
78
+ @value = p.value
79
+ @exp = p.exp
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GOBL
4
+ module Operations
5
+ # Wraps an error returned by the backend GOBL service when attempting to run an
6
+ # operation
7
+ class ServiceError < StandardError
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GOBL
4
+ module Operations
5
+ # The result of a GOBL validation over a GOBL structure
6
+ class ValidationResult
7
+ SERVICE_ERROR_REGEX = /^code=(?<code>\d+), message=(?<msg>.+)$/.freeze # @api private
8
+
9
+ def initialize(errors)
10
+ @errors = errors
11
+ end
12
+
13
+ # Whether the GOBL structure were valid or not
14
+ #
15
+ # @return [Boolean]
16
+ def valid?
17
+ @errors.empty?
18
+ end
19
+
20
+ # The list of errors found in the GOBL structure
21
+ #
22
+ # @return [Array]
23
+ def errors
24
+ @errors || []
25
+ end
26
+
27
+ # Builds a new `ValidationResult` object resulted from parsing the service
28
+ # error response given as a parameter
29
+ #
30
+ # @param service_error [String]
31
+ # @return [ValidationResult]
32
+ def self.from_service_error(service_error)
33
+ message = service_error.match(SERVICE_ERROR_REGEX)[:msg]
34
+ errors = message.split('; ')
35
+ new errors
36
+ end
37
+
38
+ # Builds a new `ValidationResult` object for a positive `validate` operation
39
+ #
40
+ # @return [ValidationResult]
41
+ def self.valid
42
+ new []
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GOBL
4
+ # Provides the API to execute operations over GOBL structures. It implements them by
5
+ # sending HTTP requests to the bulk endpoint of the service that the GOBL CLI makes
6
+ # available via the `gobl serve` command. The server's host and port must be configured
7
+ # using `GOBL.config.service_host` and `GOBL.config.service_port`.
8
+ #
9
+ # @example
10
+ # GOBL.config.service_host = 'localhost'
11
+ # GOBL.config.service_port = 8080
12
+ #
13
+ # doc = GOBL::Document.from_gobl!(
14
+ # '$schema' => 'https://gobl.org/draft-0/bill/invoice',
15
+ # 'code' => 'SAMPLE-001',
16
+ # 'currency' => 'EUR',
17
+ # 'issue_date' => '2022-02-01',
18
+ # 'supplier' => { 'tax_id' => { 'country' => 'ES', 'code' => '54387763P' }, 'name' => 'Provide One S.L.' },
19
+ # 'customer' => { 'tax_id' => { 'country' => 'ES', 'code' => '54387763P' }, 'name' => 'Sample Consumer' },
20
+ # 'lines' => [ { 'quantity' => '20', 'item' => { 'name' => 'Development services', 'price' => '90.00' } } ]
21
+ # )
22
+ #
23
+ # built_doc = GOBL.build(doc)
24
+ #
25
+ # invoice = built_doc.extract
26
+ # invoice.totals.total.to_s #=> "1800.00"
27
+ module Operations
28
+ # @api private
29
+ VALIDATABLE_TYPES = SIGNABLE_TYPES = BUILDABLE_TYPES = [
30
+ GOBL::Document,
31
+ GOBL::Envelope
32
+ ].freeze
33
+
34
+ # Calculates and validates an envelope or document, wrapping it in an envelope if
35
+ # requested.
36
+ #
37
+ # @param struct [GOBL::Document, GOBL::Envelope] the document or envelope to build.
38
+ # @param envelop [Boolean] whether the operation should envelop the document.
39
+ # @param draft [Boolean] whether the envelope should be flagged as a draft.
40
+ #
41
+ # @return [GOBL::Envelope, GOBL::Document] a built envelope or document.
42
+ #
43
+ # @raise [GOBL::Operations::ServiceError] if the service returns any errors.
44
+ #
45
+ # @example Build a document without enveloping it.
46
+ # invoice = GOBL::Document.from_json!(File.read('invoice.json'))
47
+ # GOBL.build(invoice) #=> A new, calculated `GOBL::Document`
48
+ #
49
+ # @example Build a document and wrap in a draft envelope.
50
+ # invoice = GOBL::Document.from_json!(File.read('invoice.json'))
51
+ # GOBL.build(invoice, envelop: true) #=> A calculated, draft `GOBL::Envelope`
52
+ #
53
+ # @example Build a document and wrap in a non-draft envelope.
54
+ # invoice = GOBL::Document.from_json!(File.read('invoice.json'))
55
+ # GOBL.build(invoice, envelop: true, draft: false) #=> A calculated, non-draft `GOBL::Envelope`
56
+ #
57
+ # @example Build an envelope.
58
+ # envelope = GOBL::Envelop.from_json!(File.read('envelope.json'))
59
+ # GOBL.build(envelope) #=> A new, calculated GOBL::Envelope
60
+ #
61
+ # @example Build an envelope forcing it to be a non-draft.
62
+ # envelope = GOBL::Envelop.from_json!(File.read('draft_envelope.json'))
63
+ # GOBL.build(envelope, draft: false) #=> A new, non-draft GOBL::Envelope
64
+ def build(struct, envelop: nil, draft: nil)
65
+ check_struct_type struct, BUILDABLE_TYPES
66
+
67
+ response = request_action(:build, struct: struct,
68
+ envelop: envelop,
69
+ draft: draft)
70
+
71
+ raise ServiceError, response['error'] if response['error'].present?
72
+
73
+ GOBL::Struct.from_gobl! response['payload']
74
+ end
75
+
76
+ # Checks whether or not a document or envelope is valid according to the GOBL schema
77
+ # and rules.
78
+ #
79
+ # @param struct [GOBL::Document, GOBL::Envelope] the document or the envelope to
80
+ # validate.
81
+ #
82
+ # @return [GOBL::ValidationResult] the result of the validations.
83
+ #
84
+ # @example Validate an invalid document.
85
+ # document = GOBL::Document.from_json!(File.read('invalid_invoice.json'))
86
+ # result = GOBL.validate(document)
87
+ # result.valid? #=> false
88
+ # result.errors #=> ['code: cannot be blank', 'totals: cannot be blank']
89
+ #
90
+ # @example Validate a valid envelope.
91
+ # envelope = GOBL::Envelop.from_json!(File.read('valid_envelope.json'))
92
+ # result = GOBL.validate(envelope)
93
+ # result.valid? #=> true
94
+ # result.errors #=> []
95
+ def validate(struct)
96
+ check_struct_type struct, VALIDATABLE_TYPES
97
+
98
+ response = request_action(:validate, struct: struct)
99
+
100
+ if response['error'].present?
101
+ ValidationResult.from_service_error(response['error'])
102
+ elsif response['payload']['ok']
103
+ ValidationResult.valid
104
+ else
105
+ raise 'Unexpected response from the service'
106
+ end
107
+ end
108
+
109
+ # Signs a document or envelope, calculating, enveloping and validating it first if
110
+ # needed. The signing key will be the one configured in the server.
111
+ #
112
+ # @param struct [GOBL::Document, GOBL::Envelope] the envelop or document to sign.
113
+ #
114
+ # @return [GOBL::Envelope] a signed envelope.
115
+ #
116
+ # @raise [GOBL::Operations::ServiceError] if the service returns any errors.
117
+ #
118
+ # @example Sign an envelope.
119
+ # envelope = GOBL::Envelop.from_json!(File.read('draft_envelope.json'))
120
+ # GOBL.sign(envelope) #=> A new signed GOBL::Envelope
121
+ def sign(struct)
122
+ check_struct_type struct, SIGNABLE_TYPES
123
+
124
+ response = request_action(:sign, struct: struct)
125
+
126
+ raise ServiceError, response['error'] if response['error'].present?
127
+
128
+ GOBL::Struct.from_gobl! response['payload']
129
+ end
130
+
131
+ private
132
+
133
+ # Sends a request action to the GOBL service using the bulk endpoint. This endpoint
134
+ # allows to send multiple actions in one request but we only use it with one action.
135
+ def request_action(action, struct:, **params)
136
+ request = build_bulk_request(action, struct, params)
137
+
138
+ response = Net::HTTP.post(bulk_endpoint, request)
139
+ response.error! unless response.is_a?(Net::HTTPSuccess)
140
+
141
+ parts = parse_bulk_response(response)
142
+ parts.first # We're only requesting one action, the response is the first part
143
+ end
144
+
145
+ def build_bulk_request(action, struct, params)
146
+ payload = params.merge(data: Base64.encode64(struct.to_json.to_s))
147
+
148
+ { action: action, payload: payload }.to_json
149
+ end
150
+
151
+ def bulk_endpoint
152
+ @bulk_endpoint ||= URI::HTTP.build(
153
+ host: GOBL.config.service_host,
154
+ port: GOBL.config.service_port,
155
+ path: '/bulk'
156
+ )
157
+ end
158
+
159
+ def parse_bulk_response(response)
160
+ response.body.split("\n").map { |row| JSON.parse(row) }
161
+ end
162
+
163
+ def check_struct_type(struct, allowed_types)
164
+ return if allowed_types.any? { |klass| struct.is_a?(klass) }
165
+
166
+ message = "This operation only supports #{allowed_types.join(', ')} structs"
167
+ raise ArgumentError, message
168
+ end
169
+ end
170
+ end