invoicehn 0.1.0 → 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 +4 -4
- data/CHANGELOG.md +114 -1
- data/README.md +263 -6
- data/lib/invoicehn/authorization.rb +4 -4
- data/lib/invoicehn/cli/builder.rb +15 -2
- data/lib/invoicehn/cli/wizard.rb +1 -1
- data/lib/invoicehn/compliance/validator.rb +136 -14
- data/lib/invoicehn/correlative.rb +46 -10
- data/lib/invoicehn/document_reference.rb +83 -0
- data/lib/invoicehn/invoice.rb +59 -15
- data/lib/invoicehn/issuance.rb +155 -22
- data/lib/invoicehn/ledger.rb +9 -1
- data/lib/invoicehn/renderers/json.rb +5 -2
- data/lib/invoicehn/renderers/pdf.rb +25 -5
- data/lib/invoicehn/renderers/text.rb +64 -5
- data/lib/invoicehn/settings.rb +44 -0
- data/lib/invoicehn/storage/json_store.rb +22 -7
- data/lib/invoicehn/test_support/contracts.rb +253 -0
- data/lib/invoicehn/test_support/memory.rb +112 -0
- data/lib/invoicehn/testing.rb +140 -0
- data/lib/invoicehn/version.rb +1 -1
- data/lib/invoicehn.rb +28 -0
- metadata +12 -9
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "invoicehn" unless defined?(Invoicehn::Issuance)
|
|
4
|
+
require "invoicehn/testing"
|
|
5
|
+
|
|
6
|
+
module Invoicehn
|
|
7
|
+
# Minitest modules a host application includes to run the store, sequence
|
|
8
|
+
# and ledger contracts against its own adapters:
|
|
9
|
+
#
|
|
10
|
+
# require "invoicehn/test_support/contracts"
|
|
11
|
+
#
|
|
12
|
+
# class PostgresStoreContractTest < ActiveSupport::TestCase
|
|
13
|
+
# include Invoicehn::TestSupport::StoreContract
|
|
14
|
+
#
|
|
15
|
+
# # A store holding a complete issuer and a current authorization for
|
|
16
|
+
# # contract_identifier ("000-001-01" unless overridden).
|
|
17
|
+
# def contract_store = Facturacion::InvoicehnStore.new
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# class PostgresSequenceContractTest < ActiveSupport::TestCase
|
|
21
|
+
# include Invoicehn::TestSupport::SequenceContract
|
|
22
|
+
# def contract_sequence = Facturacion::InvoicehnSequence.new
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# class LedgerContractTest < ActiveSupport::TestCase
|
|
26
|
+
# include Invoicehn::TestSupport::LedgerContract
|
|
27
|
+
# def contract_ledger = Facturacion::InvoicehnLedger.new
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# class IssuanceContractTest < ActiveSupport::TestCase
|
|
31
|
+
# include Invoicehn::TestSupport::IssuanceContract
|
|
32
|
+
# def contract_store = Facturacion::InvoicehnStore.new
|
|
33
|
+
# def contract_sequence = Facturacion::InvoicehnSequence.new
|
|
34
|
+
# def contract_ledger = Facturacion::InvoicehnLedger.new
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# The modules write documents; run them against a test database. They use
|
|
38
|
+
# only Minitest's assertions (assert, assert_equal, assert_raises, ...).
|
|
39
|
+
module TestSupport
|
|
40
|
+
# Fixed sample data, independent of the host's records.
|
|
41
|
+
module Samples
|
|
42
|
+
def contract_identifier = Issuance::DEFAULT_IDENTIFIER
|
|
43
|
+
|
|
44
|
+
def contract_sample_issuer
|
|
45
|
+
Issuer.new(rtn: "08011990123456", legal_name: "Contrato, S. de R.L.", trade_name: "Contrato",
|
|
46
|
+
headquarters_address: "Tegucigalpa, M.D.C.", phone: "2222-3333", email: "contrato@ejemplo.hn")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def contract_sample_lines
|
|
50
|
+
[LineItem.new(description: "Prueba de contrato", quantity: 2, unit_price: Money.new("50.00"),
|
|
51
|
+
treatment: :gravado_15)]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# A document numbered at the last number of `authorization`'s range, so
|
|
55
|
+
# it does not collide with the next number the host would issue.
|
|
56
|
+
def contract_sample_document(authorization:, issuer: contract_sample_issuer, **extra)
|
|
57
|
+
Invoice.new(correlative: authorization.range_end, issuer: issuer,
|
|
58
|
+
customer: Customer::Taxpayer.new(name: "Cliente de Contrato, S.A.", rtn: "05019005123456"),
|
|
59
|
+
authorization: authorization, line_items: contract_sample_lines,
|
|
60
|
+
issue_date: Invoicehn.today, **extra)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Requires #contract_store.
|
|
65
|
+
module StoreContract
|
|
66
|
+
include Samples
|
|
67
|
+
|
|
68
|
+
def contract_authorization(store = contract_store)
|
|
69
|
+
auth = store.active_authorization(contract_identifier, next_sequence: nil, on: Invoicehn.today)
|
|
70
|
+
assert auth, "contract_store debe tener una autorización vigente para #{contract_identifier}"
|
|
71
|
+
auth
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def test_store_contract_interface
|
|
75
|
+
assert Testing.assert_store_contract(contract_store)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def test_store_contract_issuer_is_complete
|
|
79
|
+
issuer = contract_store.issuer
|
|
80
|
+
|
|
81
|
+
assert_kind_of Issuer, issuer
|
|
82
|
+
assert_empty issuer.missing_fields
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_store_contract_active_authorization_covers_the_number
|
|
86
|
+
store = contract_store
|
|
87
|
+
auth = contract_authorization(store)
|
|
88
|
+
|
|
89
|
+
first = store.active_authorization(contract_identifier, next_sequence: auth.range_start.sequence,
|
|
90
|
+
on: Invoicehn.today)
|
|
91
|
+
|
|
92
|
+
assert_equal auth.to_h, first.to_h
|
|
93
|
+
beyond = auth.range_end.sequence + 1
|
|
94
|
+
covering = store.active_authorization(contract_identifier, next_sequence: beyond, on: Invoicehn.today)
|
|
95
|
+
|
|
96
|
+
assert(covering.nil? || covering.covers?(auth.range_end.with_sequence(beyond)),
|
|
97
|
+
"active_authorization devolvió una autorización que no cubre #{beyond}")
|
|
98
|
+
assert_nil store.active_authorization(contract_identifier, next_sequence: auth.range_start.sequence,
|
|
99
|
+
on: auth.limit_date + 1)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def test_store_contract_round_trips_save_find_delete
|
|
103
|
+
store = contract_store
|
|
104
|
+
document = contract_sample_document(authorization: contract_authorization(store))
|
|
105
|
+
|
|
106
|
+
refute store.exists?(document.correlative)
|
|
107
|
+
store.save_document(document)
|
|
108
|
+
|
|
109
|
+
assert store.exists?(document.correlative)
|
|
110
|
+
assert_equal document.to_h, store.find(document.correlative).to_h
|
|
111
|
+
assert_raises(ImmutableDocument) { store.save_document(document) }
|
|
112
|
+
|
|
113
|
+
store.delete_document(document)
|
|
114
|
+
|
|
115
|
+
refute store.exists?(document.correlative)
|
|
116
|
+
assert_raises(DocumentNotFound) { store.find(document.correlative) }
|
|
117
|
+
ensure
|
|
118
|
+
# A failure above must not leave the sample behind for the next test.
|
|
119
|
+
store&.delete_document(document) if document
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Art. 41 — the annulled form replaces the record; the number stays.
|
|
123
|
+
def test_store_contract_keeps_the_annulled_form
|
|
124
|
+
store = contract_store
|
|
125
|
+
document = contract_sample_document(authorization: contract_authorization(store))
|
|
126
|
+
store.save_document(document)
|
|
127
|
+
store.save_document(document.annul(reason: "Prueba de contrato"))
|
|
128
|
+
|
|
129
|
+
found = store.find(document.correlative)
|
|
130
|
+
|
|
131
|
+
assert_predicate found, :annulled?
|
|
132
|
+
assert_equal "Prueba de contrato", found.annulment_reason
|
|
133
|
+
ensure
|
|
134
|
+
store&.delete_document(document) if document
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A note's reference and motivo (Arts. 26 and 28 num. 3-4) must survive
|
|
138
|
+
# the store. Runs only when the store holds an authorization for
|
|
139
|
+
# contract_note_identifier.
|
|
140
|
+
def test_store_contract_keeps_a_notes_reference_and_motivo
|
|
141
|
+
store = contract_store
|
|
142
|
+
auth = store.active_authorization(contract_note_identifier, next_sequence: nil, on: Invoicehn.today)
|
|
143
|
+
skip "sin autorización para #{contract_note_identifier}" unless auth
|
|
144
|
+
|
|
145
|
+
reference = DocumentReference.new(correlative: "#{contract_identifier}-00000001", cai: "CAI-CONTRATO",
|
|
146
|
+
issue_date: Invoicehn.today)
|
|
147
|
+
note = contract_sample_document(authorization: auth, reference: reference, motivo: "Devolución")
|
|
148
|
+
store.save_document(note)
|
|
149
|
+
found = store.find(note.correlative)
|
|
150
|
+
|
|
151
|
+
assert_equal reference, found.reference
|
|
152
|
+
assert_equal "Devolución", found.motivo
|
|
153
|
+
assert_equal note.to_h, found.to_h
|
|
154
|
+
ensure
|
|
155
|
+
store&.delete_document(note) if note
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def contract_note_identifier = "#{contract_identifier.split("-")[0, 2].join("-")}-06"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Requires #contract_sequence.
|
|
162
|
+
module SequenceContract
|
|
163
|
+
include Samples
|
|
164
|
+
|
|
165
|
+
def contract_other_identifier = "#{contract_identifier.split("-")[0, 2].join("-")}-07"
|
|
166
|
+
|
|
167
|
+
def test_sequence_contract_interface
|
|
168
|
+
assert Testing.assert_sequence_contract(contract_sequence)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def test_sequence_contract_peek_does_not_consume
|
|
172
|
+
sequence = contract_sequence
|
|
173
|
+
before = sequence.issued_count(contract_identifier)
|
|
174
|
+
|
|
175
|
+
assert_equal sequence.peek(contract_identifier), sequence.peek(contract_identifier)
|
|
176
|
+
assert_equal before + 1, sequence.peek(contract_identifier).sequence
|
|
177
|
+
assert_equal before, sequence.issued_count(contract_identifier)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def test_sequence_contract_allocate_yields_the_next_number_and_advances
|
|
181
|
+
sequence = contract_sequence
|
|
182
|
+
expected = sequence.peek(contract_identifier)
|
|
183
|
+
yielded = nil
|
|
184
|
+
|
|
185
|
+
result = sequence.allocate(contract_identifier) do |correlative|
|
|
186
|
+
yielded = correlative
|
|
187
|
+
:resultado
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
assert_equal expected, yielded
|
|
191
|
+
assert_equal :resultado, result
|
|
192
|
+
assert_equal expected.sequence, sequence.issued_count(contract_identifier)
|
|
193
|
+
assert_equal expected.succ, sequence.peek(contract_identifier)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The property Issuance's all-or-nothing guarantee rests on.
|
|
197
|
+
def test_sequence_contract_a_failing_block_consumes_nothing
|
|
198
|
+
sequence = contract_sequence
|
|
199
|
+
before = sequence.issued_count(contract_identifier)
|
|
200
|
+
|
|
201
|
+
assert_raises(ArgumentError) { sequence.allocate(contract_identifier) { raise ArgumentError, "fallo" } }
|
|
202
|
+
assert_equal before, sequence.issued_count(contract_identifier)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Each identificador del documento runs its own series (Art. 10 num. 7).
|
|
206
|
+
def test_sequence_contract_identifiers_are_independent
|
|
207
|
+
sequence = contract_sequence
|
|
208
|
+
other = sequence.issued_count(contract_other_identifier)
|
|
209
|
+
sequence.allocate(contract_identifier) { |c| c }
|
|
210
|
+
|
|
211
|
+
assert_equal other, sequence.issued_count(contract_other_identifier)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Requires #contract_ledger.
|
|
216
|
+
module LedgerContract
|
|
217
|
+
include Samples
|
|
218
|
+
|
|
219
|
+
def contract_ledger_document
|
|
220
|
+
auth = Authorization.new(cai: "CAI-CONTRATO", range_start: "#{contract_identifier}-00000001",
|
|
221
|
+
range_end: "#{contract_identifier}-00000010", limit_date: Invoicehn.today + 30)
|
|
222
|
+
contract_sample_document(authorization: auth)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def test_ledger_contract_interface
|
|
226
|
+
assert Testing.assert_ledger_contract(contract_ledger)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def test_ledger_contract_records_issue_and_annulment
|
|
230
|
+
ledger = contract_ledger
|
|
231
|
+
document = contract_ledger_document
|
|
232
|
+
|
|
233
|
+
ledger.record(document, event: :emision)
|
|
234
|
+
ledger.record(document.annul(reason: "Prueba de contrato"), event: :anulacion)
|
|
235
|
+
|
|
236
|
+
pass
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Requires #contract_store, #contract_sequence and #contract_ledger,
|
|
241
|
+
# wired to each other. Issues one real Factura.
|
|
242
|
+
module IssuanceContract
|
|
243
|
+
include Samples
|
|
244
|
+
|
|
245
|
+
def test_issuance_contract_round_trip_and_rollback
|
|
246
|
+
document = Testing.assert_issuance_round_trip(store: contract_store, sequence: contract_sequence,
|
|
247
|
+
ledger: contract_ledger, identifier: contract_identifier)
|
|
248
|
+
|
|
249
|
+
assert_equal contract_identifier, document.correlative.identifier
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "invoicehn" unless defined?(Invoicehn::Issuance)
|
|
5
|
+
|
|
6
|
+
module Invoicehn
|
|
7
|
+
module TestSupport
|
|
8
|
+
# In-memory store, sequence and ledger. They exist for tests, and as the
|
|
9
|
+
# shortest complete example of the contract a host adapter (Postgres,
|
|
10
|
+
# say) has to meet — each method here is one the README lists. They hold
|
|
11
|
+
# nothing across processes and must never back real fiscal documents.
|
|
12
|
+
|
|
13
|
+
# The store: issuer, authorizations and documents.
|
|
14
|
+
class MemoryStore
|
|
15
|
+
attr_reader :documents, :issuer
|
|
16
|
+
|
|
17
|
+
def initialize(issuer: nil, authorizations: [])
|
|
18
|
+
@issuer = issuer
|
|
19
|
+
@authorizations = authorizations.dup
|
|
20
|
+
@documents = {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def save_issuer(issuer)
|
|
24
|
+
@issuer = issuer
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_authorization(authorization)
|
|
28
|
+
@authorizations << authorization
|
|
29
|
+
authorization
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def authorizations_for(identifier) = @authorizations.select { |a| a.identifier == identifier }
|
|
33
|
+
|
|
34
|
+
# Unexpired, and — when next_sequence is given — covering that number.
|
|
35
|
+
def active_authorization(identifier, next_sequence: nil, on: Invoicehn.today)
|
|
36
|
+
candidates = authorizations_for(identifier).reject { |a| a.expired?(on) }
|
|
37
|
+
if next_sequence
|
|
38
|
+
correlative = Correlative.parse(format("%<id>s-%<n>08d", id: identifier, n: next_sequence))
|
|
39
|
+
candidates = candidates.select { |a| a.covers?(correlative) }
|
|
40
|
+
end
|
|
41
|
+
candidates.min_by { |a| a.range_start.sequence }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# An issued document is immutable; only its annulled form may replace it
|
|
45
|
+
# (Art. 41).
|
|
46
|
+
def save_document(document)
|
|
47
|
+
key = document.correlative.to_s
|
|
48
|
+
if @documents.key?(key) && !document.annulled?
|
|
49
|
+
raise ImmutableDocument, "el documento #{key} ya está registrado y no puede modificarse (Art. 41)"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
@documents[key] = document.to_h
|
|
53
|
+
document
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def delete_document(document)
|
|
57
|
+
@documents.delete(document.correlative.to_s)
|
|
58
|
+
document
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def find(correlative)
|
|
62
|
+
hash = @documents[correlative.to_s]
|
|
63
|
+
raise DocumentNotFound, "no existe el documento #{correlative}" unless hash
|
|
64
|
+
|
|
65
|
+
Invoice.from_h(hash)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def exists?(correlative) = @documents.key?(correlative.to_s)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The counter per identifier. The block runs inside the lock and the
|
|
72
|
+
# counter advances only after it returns — the property that keeps a
|
|
73
|
+
# failed issue from burning a number.
|
|
74
|
+
class MemorySequence
|
|
75
|
+
def initialize
|
|
76
|
+
@counters = Hash.new(0)
|
|
77
|
+
@lock = Monitor.new
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def peek(identifier) = @lock.synchronize { build(identifier, @counters[identifier] + 1) }
|
|
81
|
+
|
|
82
|
+
def issued_count(identifier) = @lock.synchronize { @counters[identifier] }
|
|
83
|
+
|
|
84
|
+
def allocate(identifier)
|
|
85
|
+
@lock.synchronize do
|
|
86
|
+
correlative = build(identifier, @counters[identifier] + 1)
|
|
87
|
+
result = block_given? ? yield(correlative) : correlative
|
|
88
|
+
@counters[identifier] = correlative.sequence
|
|
89
|
+
result
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def build(identifier, sequence) = Correlative.parse(format("%<id>s-%<n>08d", id: identifier, n: sequence))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Keeps the entries it is handed.
|
|
99
|
+
class MemoryLedger
|
|
100
|
+
attr_reader :records
|
|
101
|
+
|
|
102
|
+
def initialize
|
|
103
|
+
@records = []
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def record(document, event: :emision)
|
|
107
|
+
@records << [event, document.correlative.to_s]
|
|
108
|
+
document
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "invoicehn" unless defined?(Invoicehn::Issuance)
|
|
4
|
+
|
|
5
|
+
module Invoicehn
|
|
6
|
+
# Checks a host application's store, sequence and ledger against the
|
|
7
|
+
# contract Invoicehn::Issuance relies on. Not loaded by `require
|
|
8
|
+
# "invoicehn"`; a host's test suite opts in:
|
|
9
|
+
#
|
|
10
|
+
# require "invoicehn/testing"
|
|
11
|
+
#
|
|
12
|
+
# Invoicehn::Testing.assert_store_contract(PostgresStore.new)
|
|
13
|
+
# Invoicehn::Testing.assert_sequence_contract(PostgresSequence.new)
|
|
14
|
+
# Invoicehn::Testing.assert_ledger_contract(AccountingLedger.new)
|
|
15
|
+
# Invoicehn::Testing.assert_issuance_round_trip(store: ..., sequence: ..., ledger: ...)
|
|
16
|
+
#
|
|
17
|
+
# Every check raises Invoicehn::Testing::ContractError listing all the
|
|
18
|
+
# problems it found, and returns the checked object (or, for the round trip,
|
|
19
|
+
# the issued document) otherwise, so it works under minitest, RSpec or a
|
|
20
|
+
# plain script.
|
|
21
|
+
module Testing
|
|
22
|
+
# Raised when an implementation does not satisfy the contract.
|
|
23
|
+
class ContractError < Error; end
|
|
24
|
+
|
|
25
|
+
# name => [positional parameters, keyword parameters]
|
|
26
|
+
STORE_CONTRACT = {
|
|
27
|
+
issuer: [0, []],
|
|
28
|
+
authorizations_for: [1, []],
|
|
29
|
+
active_authorization: [1, %i[next_sequence on]],
|
|
30
|
+
save_document: [1, []],
|
|
31
|
+
delete_document: [1, []],
|
|
32
|
+
find: [1, []],
|
|
33
|
+
exists?: [1, []]
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
SEQUENCE_CONTRACT = {
|
|
37
|
+
allocate: [1, []],
|
|
38
|
+
peek: [1, []],
|
|
39
|
+
issued_count: [1, []]
|
|
40
|
+
}.freeze
|
|
41
|
+
|
|
42
|
+
LEDGER_CONTRACT = {
|
|
43
|
+
record: [1, %i[event]]
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# A ledger that refuses every entry, used to prove the rollback.
|
|
47
|
+
class RefusingLedger
|
|
48
|
+
class Refused < StandardError; end
|
|
49
|
+
|
|
50
|
+
def record(_document, event: :emision)
|
|
51
|
+
raise Refused, "asiento rechazado a propósito (#{event})"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
def assert_store_contract(store) = assert_contract(store, STORE_CONTRACT, "store")
|
|
57
|
+
def assert_sequence_contract(sequence) = assert_contract(sequence, SEQUENCE_CONTRACT, "sequence")
|
|
58
|
+
def assert_ledger_contract(ledger) = assert_contract(ledger, LEDGER_CONTRACT, "ledger")
|
|
59
|
+
|
|
60
|
+
# Behavioural check, against a store that already holds a complete
|
|
61
|
+
# issuer and a current authorization for `identifier`. Issues ONE real
|
|
62
|
+
# Factura, so run it against a test database.
|
|
63
|
+
#
|
|
64
|
+
# 1. With a ledger that refuses the entry, issuing must raise and leave
|
|
65
|
+
# nothing behind: no document at the next number, counter unchanged.
|
|
66
|
+
# This is what a Postgres adapter gets wrong when allocate advances
|
|
67
|
+
# the counter outside the block, or delete_document is a no-op.
|
|
68
|
+
# 2. With the real ledger, the document must come back from find equal
|
|
69
|
+
# to what was issued, and the counter must have advanced by one.
|
|
70
|
+
#
|
|
71
|
+
# @return [Invoice] the document issued in step 2
|
|
72
|
+
def assert_issuance_round_trip(store:, sequence:, ledger:, identifier: Issuance::DEFAULT_IDENTIFIER,
|
|
73
|
+
customer: Customer::ConsumidorFinal.new, line_items: nil)
|
|
74
|
+
line_items ||= [LineItem.new(description: "Prueba de contrato", quantity: 1,
|
|
75
|
+
unit_price: Money.new("1.00"), treatment: :exento)]
|
|
76
|
+
before = sequence.issued_count(identifier)
|
|
77
|
+
upcoming = sequence.peek(identifier)
|
|
78
|
+
problems = []
|
|
79
|
+
|
|
80
|
+
refusing = Issuance.new(store: store, sequence: sequence, ledger: RefusingLedger.new)
|
|
81
|
+
begin
|
|
82
|
+
refusing.issue(customer: customer, line_items: line_items, identifier: identifier)
|
|
83
|
+
problems << "issue no propagó el error del ledger"
|
|
84
|
+
rescue RefusingLedger::Refused
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
problems << "el documento #{upcoming} quedó guardado aunque el ledger lo rechazó" if store.exists?(upcoming)
|
|
88
|
+
if sequence.issued_count(identifier) != before
|
|
89
|
+
problems << "el contador avanzó aunque la emisión falló (allocate debe avanzar sólo si el bloque termina)"
|
|
90
|
+
end
|
|
91
|
+
raise ContractError, problems.join("\n") if problems.any?
|
|
92
|
+
|
|
93
|
+
document = Issuance.new(store: store, sequence: sequence, ledger: ledger)
|
|
94
|
+
.issue(customer: customer, line_items: line_items, identifier: identifier)
|
|
95
|
+
|
|
96
|
+
problems << "se emitió #{document.correlative}, se esperaba #{upcoming}" unless document.correlative == upcoming
|
|
97
|
+
problems << "issued_count no avanzó en uno" unless sequence.issued_count(identifier) == before + 1
|
|
98
|
+
problems << "exists? no encuentra #{document.correlative}" unless store.exists?(document.correlative)
|
|
99
|
+
unless store.find(document.correlative).to_h == document.to_h
|
|
100
|
+
problems << "find(#{document.correlative}) no devuelve el documento tal como se emitió"
|
|
101
|
+
end
|
|
102
|
+
raise ContractError, problems.join("\n") if problems.any?
|
|
103
|
+
|
|
104
|
+
document
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def assert_contract(object, contract, role)
|
|
110
|
+
problems = contract.flat_map do |name, (positional, keywords)|
|
|
111
|
+
next ["#{role} debe responder a ##{name}"] unless object.respond_to?(name)
|
|
112
|
+
|
|
113
|
+
signature_problems(object.method(name), role, name, positional, keywords)
|
|
114
|
+
end
|
|
115
|
+
raise ContractError, problems.join("\n") if problems.any?
|
|
116
|
+
|
|
117
|
+
object
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def signature_problems(method, role, name, positional, keywords)
|
|
121
|
+
params = method.parameters
|
|
122
|
+
problems = []
|
|
123
|
+
|
|
124
|
+
required = params.count { |type, _| type == :req }
|
|
125
|
+
accepts = params.count { |type, _| %i[req opt].include?(type) }
|
|
126
|
+
rest = params.any? { |type, _| type == :rest }
|
|
127
|
+
unless required <= positional && (rest || accepts >= positional)
|
|
128
|
+
problems << "#{role}##{name} debe aceptar #{positional} argumento(s) posicional(es)"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
keyrest = params.any? { |type, _| type == :keyrest }
|
|
132
|
+
declared = params.filter_map { |type, param| param if %i[key keyreq].include?(type) }
|
|
133
|
+
(keywords - declared).each do |kw|
|
|
134
|
+
problems << "#{role}##{name} debe aceptar la palabra clave #{kw}:" unless keyrest
|
|
135
|
+
end
|
|
136
|
+
problems
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
data/lib/invoicehn/version.rb
CHANGED
data/lib/invoicehn.rb
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "invoicehn/version"
|
|
4
4
|
require_relative "invoicehn/errors"
|
|
5
|
+
require_relative "invoicehn/settings"
|
|
5
6
|
require_relative "invoicehn/locale"
|
|
6
7
|
require_relative "invoicehn/money"
|
|
7
8
|
require_relative "invoicehn/spanish_numerals"
|
|
8
9
|
require_relative "invoicehn/rtn"
|
|
9
10
|
require_relative "invoicehn/correlative"
|
|
11
|
+
require_relative "invoicehn/document_reference"
|
|
10
12
|
require_relative "invoicehn/tax_treatment"
|
|
11
13
|
require_relative "invoicehn/line_item"
|
|
12
14
|
require_relative "invoicehn/tax_summary"
|
|
@@ -37,4 +39,30 @@ require_relative "invoicehn/renderers/pdf"
|
|
|
37
39
|
# límite de emisión (Arts. 59-61) all remain the operator's obligations before
|
|
38
40
|
# any document produced here is valid.
|
|
39
41
|
module Invoicehn
|
|
42
|
+
# Invoicehn.configure / Invoicehn.today — see Invoicehn::Settings.
|
|
43
|
+
class << self
|
|
44
|
+
def settings
|
|
45
|
+
@settings ||= Settings.new
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def configure
|
|
49
|
+
yield settings
|
|
50
|
+
settings
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Restores the defaults. Meant for test suites.
|
|
54
|
+
def reset_settings!
|
|
55
|
+
@settings = Settings.new
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Today's date in Honduras, as the configured clock reports it. Raises if
|
|
59
|
+
# the clock returns something that is not a Date — a Time or a String here
|
|
60
|
+
# would silently shift fiscal dates.
|
|
61
|
+
def today
|
|
62
|
+
value = settings.today.call
|
|
63
|
+
return value if value.instance_of?(Date)
|
|
64
|
+
|
|
65
|
+
raise Error, "el reloj configurado devolvió #{value.inspect}; se espera un Date"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
40
68
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: invoicehn
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jorge Padilla
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: bigdecimal
|
|
@@ -81,9 +80,10 @@ dependencies:
|
|
|
81
80
|
- !ruby/object:Gem::Version
|
|
82
81
|
version: '0.23'
|
|
83
82
|
description: |
|
|
84
|
-
Genera Facturas
|
|
85
|
-
Reglamento del Régimen de Facturación (Acuerdo 481-2017
|
|
86
|
-
correlativo de 16 dígitos
|
|
83
|
+
Genera Facturas, Notas de Crédito y Notas de Débito que cumplen los requisitos de contenido
|
|
84
|
+
de los Artículos 10-11 y 25-28 del Reglamento del Régimen de Facturación (Acuerdo 481-2017
|
|
85
|
+
y sus reformas), incluyendo el correlativo de 16 dígitos con los códigos de tipo de
|
|
86
|
+
documento reformados, control del CAI, rango autorizado y fecha límite de emisión,
|
|
87
87
|
discriminación del ISV por tarifa, y el redondeo estatutario del Artículo 9 de la Ley
|
|
88
88
|
del Impuesto Sobre Ventas. Incluye biblioteca Ruby y una interfaz de terminal.
|
|
89
89
|
email:
|
|
@@ -111,6 +111,7 @@ files:
|
|
|
111
111
|
- lib/invoicehn/config.rb
|
|
112
112
|
- lib/invoicehn/correlative.rb
|
|
113
113
|
- lib/invoicehn/customer.rb
|
|
114
|
+
- lib/invoicehn/document_reference.rb
|
|
114
115
|
- lib/invoicehn/errors.rb
|
|
115
116
|
- lib/invoicehn/exchange_rate.rb
|
|
116
117
|
- lib/invoicehn/invoice.rb
|
|
@@ -125,10 +126,14 @@ files:
|
|
|
125
126
|
- lib/invoicehn/renderers/text.rb
|
|
126
127
|
- lib/invoicehn/rtn.rb
|
|
127
128
|
- lib/invoicehn/sequence.rb
|
|
129
|
+
- lib/invoicehn/settings.rb
|
|
128
130
|
- lib/invoicehn/spanish_numerals.rb
|
|
129
131
|
- lib/invoicehn/storage/json_store.rb
|
|
130
132
|
- lib/invoicehn/tax_summary.rb
|
|
131
133
|
- lib/invoicehn/tax_treatment.rb
|
|
134
|
+
- lib/invoicehn/test_support/contracts.rb
|
|
135
|
+
- lib/invoicehn/test_support/memory.rb
|
|
136
|
+
- lib/invoicehn/testing.rb
|
|
132
137
|
- lib/invoicehn/version.rb
|
|
133
138
|
homepage: https://github.com/JorgePadilla/invoicehn
|
|
134
139
|
licenses:
|
|
@@ -139,7 +144,6 @@ metadata:
|
|
|
139
144
|
changelog_uri: https://github.com/JorgePadilla/invoicehn/blob/main/CHANGELOG.md
|
|
140
145
|
bug_tracker_uri: https://github.com/JorgePadilla/invoicehn/issues
|
|
141
146
|
rubygems_mfa_required: 'true'
|
|
142
|
-
post_install_message:
|
|
143
147
|
rdoc_options: []
|
|
144
148
|
require_paths:
|
|
145
149
|
- lib
|
|
@@ -154,8 +158,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
154
158
|
- !ruby/object:Gem::Version
|
|
155
159
|
version: '0'
|
|
156
160
|
requirements: []
|
|
157
|
-
rubygems_version: 3.
|
|
158
|
-
signing_key:
|
|
161
|
+
rubygems_version: 3.6.9
|
|
159
162
|
specification_version: 4
|
|
160
163
|
summary: Facturación para Honduras conforme al Régimen de Facturación (Acuerdo 481-2017)
|
|
161
164
|
test_files: []
|