sunat_invoice 0.0.1 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile +1 -0
- data/README.mkd +1 -25
- data/lib/sunat_invoice/client.rb +3 -6
- data/lib/sunat_invoice/clients/consult_client.rb +17 -0
- data/lib/sunat_invoice/clients/invoice_client.rb +11 -3
- data/lib/sunat_invoice/configuration.rb +1 -1
- data/lib/sunat_invoice/credit_note.rb +59 -0
- data/lib/sunat_invoice/credit_note_line.rb +15 -0
- data/lib/sunat_invoice/customer.rb +1 -0
- data/lib/sunat_invoice/daily_summary.rb +46 -0
- data/lib/sunat_invoice/debit_note.rb +33 -0
- data/lib/sunat_invoice/debit_note_line.rb +15 -0
- data/lib/sunat_invoice/invoice.rb +23 -144
- data/lib/sunat_invoice/item.rb +40 -26
- data/lib/sunat_invoice/line.rb +18 -0
- data/lib/sunat_invoice/provider.rb +20 -14
- data/lib/sunat_invoice/response_parser.rb +64 -0
- data/lib/sunat_invoice/signature.rb +29 -15
- data/lib/sunat_invoice/summary_line.rb +75 -0
- data/lib/sunat_invoice/tax.rb +6 -1
- data/lib/sunat_invoice/trade_calculations.rb +68 -0
- data/lib/sunat_invoice/trade_document.rb +85 -0
- data/lib/sunat_invoice/utils.rb +48 -9
- data/lib/sunat_invoice/voided.rb +22 -0
- data/lib/sunat_invoice/voided_line.rb +20 -0
- data/lib/sunat_invoice/xml_document.rb +52 -0
- data/lib/sunat_invoice.rb +14 -0
- data/sunat_invoice.gemspec +5 -5
- data/test/credit_note_test.rb +15 -0
- data/test/daily_summary_test.rb +35 -0
- data/test/debit_note_test.rb +15 -0
- data/test/factories.rb +70 -0
- data/test/fixtures/responses/invoice_success.xml +2 -0
- data/test/fixtures/responses/summary_success.xml +2 -0
- data/test/fixtures/responses/ticket_invalid.xml +2 -0
- data/test/helper.rb +1 -0
- data/test/invoice_client_test.rb +68 -16
- data/test/invoice_test.rb +19 -10
- data/test/item_test.rb +3 -2
- data/test/response_parser_test.rb +28 -0
- data/test/support/response_helper.rb +18 -0
- data/test/support/signature_helper.rb +15 -8
- metadata +34 -13
@@ -0,0 +1,64 @@
|
|
1
|
+
# frozen_string_literal: false
|
2
|
+
|
3
|
+
module SunatInvoice
|
4
|
+
class InvalidResponseParser < StandardError; end
|
5
|
+
|
6
|
+
class ResponseParser
|
7
|
+
attr_reader :cdr, :status_code, :document_number, :message, :ticket
|
8
|
+
|
9
|
+
STATUS_CODES = {
|
10
|
+
0 => 'process success',
|
11
|
+
99 => 'in process',
|
12
|
+
98 => 'process with errors'
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
ALLOWED_PARSERS = %w[invoice summary status].freeze
|
16
|
+
VALID_PROCESS = %w[0 99].freeze
|
17
|
+
|
18
|
+
def initialize(body, parser_type)
|
19
|
+
# body: SOAP body as a Hash. Typically Savon Response body.
|
20
|
+
# parser_type: kind of parser to use.
|
21
|
+
raise InvalidResponseParser unless ALLOWED_PARSERS.include?(parser_type)
|
22
|
+
send("parse_#{parser_type}", body)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def parse_invoice(body)
|
28
|
+
encrypted_zip = body[:send_bill_response][:application_response]
|
29
|
+
decrypt_zip(encrypted_zip)
|
30
|
+
end
|
31
|
+
|
32
|
+
def parse_xml(cdr_xml)
|
33
|
+
@cdr = Nokogiri::XML(cdr_xml)
|
34
|
+
response_node = @cdr.at('//cac:DocumentResponse/cac:Response')
|
35
|
+
@status_code = response_node.at('//cbc:ResponseCode').content
|
36
|
+
@document_number = response_node.at('//cbc:ReferenceID').content
|
37
|
+
@message = response_node.at('//cbc:Description').content
|
38
|
+
end
|
39
|
+
|
40
|
+
def parse_summary(body)
|
41
|
+
@ticket = body[:send_summary_response][:ticket]
|
42
|
+
end
|
43
|
+
|
44
|
+
def parse_status(body)
|
45
|
+
status_hash = body[:get_status_response][:status]
|
46
|
+
@status_code = status_hash[:status_code]
|
47
|
+
if VALID_PROCESS.include?(status_code)
|
48
|
+
encrypted_zip = status_hash[:content]
|
49
|
+
decrypt_zip(encrypted_zip)
|
50
|
+
else
|
51
|
+
@message = status_hash[:content]
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def decrypt_zip(encrypted_zip)
|
56
|
+
decoded = Base64.decode64(encrypted_zip)
|
57
|
+
Zip::InputStream.open(StringIO.new(decoded)) do |io|
|
58
|
+
while (entry = io.get_next_entry)
|
59
|
+
parse_xml(io.read) if entry.name.include?('.xml')
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -18,24 +18,34 @@ module SunatInvoice
|
|
18
18
|
def signer_data(xml)
|
19
19
|
xml['cac'].Signature do
|
20
20
|
xml['cbc'].ID provider.signature_id
|
21
|
-
xml
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
21
|
+
build_signatory_party(xml)
|
22
|
+
build_digital_attachment(xml)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def build_signatory_party(xml)
|
27
|
+
xml['cac'].SignatoryParty do
|
28
|
+
xml['cac'].PartyIdentification do
|
29
|
+
xml['cbc'].ID provider.ruc
|
28
30
|
end
|
29
|
-
xml['cac'].
|
30
|
-
xml['
|
31
|
-
|
32
|
-
|
31
|
+
xml['cac'].PartyName do
|
32
|
+
xml['cbc'].Name provider.name
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def build_digital_attachment(xml)
|
38
|
+
xml['cac'].DigitalSignatureAttachment do
|
39
|
+
xml['cac'].ExternalReference do
|
40
|
+
xml['cbc'].URI "##{provider.signature_location_id}"
|
33
41
|
end
|
34
42
|
end
|
35
43
|
end
|
36
44
|
|
37
45
|
def sign(invoice_xml)
|
38
|
-
|
46
|
+
options = { id_attr: provider.signature_location_id }
|
47
|
+
doc = Xmldsig::SignedDocument.new(invoice_xml, options)
|
48
|
+
doc.sign(private_key)
|
39
49
|
end
|
40
50
|
|
41
51
|
def signature_ext(xml)
|
@@ -52,15 +62,19 @@ module SunatInvoice
|
|
52
62
|
xml['ds'].CanonicalizationMethod Algorithm: C14N_ALGORITHM
|
53
63
|
xml['ds'].SignatureMethod Algorithm: SIGNATURE_ALGORITHM
|
54
64
|
xml['ds'].Reference URI: '' do
|
55
|
-
xml
|
56
|
-
xml['ds'].Transform Algorithm: TRANSFORMATION_ALGORITHM
|
57
|
-
end
|
65
|
+
build_transforms(xml)
|
58
66
|
xml['ds'].DigestMethod Algorithm: DIGEST_ALGORITHM
|
59
67
|
xml['ds'].DigestValue
|
60
68
|
end
|
61
69
|
end
|
62
70
|
end
|
63
71
|
|
72
|
+
def build_transforms(xml)
|
73
|
+
xml['ds'].Transforms do
|
74
|
+
xml['ds'].Transform Algorithm: TRANSFORMATION_ALGORITHM
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
64
78
|
def signature_value(xml)
|
65
79
|
xml['ds'].SignatureValue
|
66
80
|
xml['ds'].KeyInfo do
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'line'
|
4
|
+
|
5
|
+
module SunatInvoice
|
6
|
+
class SummaryLine < Line
|
7
|
+
attr_accessor :document_type, :document_serial, :start_document_number,
|
8
|
+
:end_document_number, :total_amount, :taxable, :non_taxable,
|
9
|
+
:exempt, :other_charge, :charge_type
|
10
|
+
|
11
|
+
CHARGES = {
|
12
|
+
discount: false,
|
13
|
+
charge: true
|
14
|
+
}.freeze
|
15
|
+
|
16
|
+
def initialize(*args)
|
17
|
+
super(*args)
|
18
|
+
@taxable ||= 0.01
|
19
|
+
@non_taxable ||= 0.01
|
20
|
+
@exempt ||= 0.01
|
21
|
+
@other_charge ||= 0.01
|
22
|
+
end
|
23
|
+
|
24
|
+
def xml(xml, index, currency)
|
25
|
+
xml['sac'].SummaryDocumentsLine do
|
26
|
+
xml['cbc'].LineID(index + 1)
|
27
|
+
build_documents_info(xml)
|
28
|
+
amount_xml(xml['sac'], 'TotalAmount', total_amount, currency)
|
29
|
+
build_payments(xml, currency)
|
30
|
+
build_other_charge(xml, currency)
|
31
|
+
build_taxes_xml(xml, currency)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def payments
|
38
|
+
[{ amount: taxable, code: '01' },
|
39
|
+
{ amount: exempt, code: '02' },
|
40
|
+
{ amount: non_taxable, code: '03' }]
|
41
|
+
end
|
42
|
+
|
43
|
+
def build_documents_info(xml)
|
44
|
+
xml['cbc'].DocumentTypeCode document_type
|
45
|
+
xml['sac'].DocumentSerialID document_serial
|
46
|
+
xml['sac'].StartDocumentNumberID start_document_number
|
47
|
+
xml['sac'].EndDocumentNumberID end_document_number
|
48
|
+
end
|
49
|
+
|
50
|
+
def calculate_total_amount
|
51
|
+
return if total_amount
|
52
|
+
# TODO: sum(billing payments) + allowance charge + sum(taxes)
|
53
|
+
end
|
54
|
+
|
55
|
+
def build_payments(xml, currency)
|
56
|
+
payments.each do |payment|
|
57
|
+
xml['sac'].BillingPayment do
|
58
|
+
amount_xml(xml['cbc'], 'PaidAmount', payment[:amount], currency)
|
59
|
+
xml['cbc'].InstructionID payment[:code]
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def build_other_charge(xml, currency)
|
65
|
+
xml['cac'].AllowanceCharge do
|
66
|
+
xml['cbc'].ChargeIndicator CHARGES.values.last
|
67
|
+
amount_xml(xml['cbc'], 'Amount', other_charge, currency)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def resolve_charge_type
|
72
|
+
charge_type ? CHARGES[charge_type] : CHARGES.values.first
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
data/lib/sunat_invoice/tax.rb
CHANGED
@@ -41,7 +41,7 @@ module SunatInvoice
|
|
41
41
|
|
42
42
|
def tax_category(xml)
|
43
43
|
xml['cac'].TaxCategory do
|
44
|
-
xml
|
44
|
+
tax_exemption(xml)
|
45
45
|
xml['cbc'].TierRange(tier_range) if tier_range
|
46
46
|
tax_scheme(xml)
|
47
47
|
end
|
@@ -58,5 +58,10 @@ module SunatInvoice
|
|
58
58
|
def tax_data(attribute)
|
59
59
|
TAXES[tax_type][attribute]
|
60
60
|
end
|
61
|
+
|
62
|
+
def tax_exemption(xml)
|
63
|
+
return unless tax_exemption_reason
|
64
|
+
xml['cbc'].TaxExemptionReasonCode(tax_exemption_reason)
|
65
|
+
end
|
61
66
|
end
|
62
67
|
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: false
|
2
|
+
|
3
|
+
require_relative 'catalogs'
|
4
|
+
|
5
|
+
module SunatInvoice
|
6
|
+
module TradeCalculations
|
7
|
+
def prepare_totals
|
8
|
+
calculate_taxes_totals
|
9
|
+
calculate_sale_totals
|
10
|
+
calculate_total
|
11
|
+
end
|
12
|
+
|
13
|
+
def calculate_total
|
14
|
+
# calculate invoice total
|
15
|
+
@total = 0
|
16
|
+
@total += @taxes_totals.values.sum
|
17
|
+
@total += @sale_totals.reject { |k, _v| k == '1004' }.values.sum
|
18
|
+
@total -= discount if discount
|
19
|
+
end
|
20
|
+
|
21
|
+
def calculate_sale_totals
|
22
|
+
@sale_totals = {}
|
23
|
+
# get bi totals according kind of sale (gravado, inafecto, exonerado ..)
|
24
|
+
lines&.each do |item|
|
25
|
+
# TODO: I think in most cases only be one tax for item, but should
|
26
|
+
# handle more cases
|
27
|
+
total_code = get_total_code(item.taxes.first)
|
28
|
+
if total_code
|
29
|
+
@sale_totals[total_code] = 0 unless @sale_totals[total_code]
|
30
|
+
@sale_totals[total_code] += item.bi_value
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def calculate_taxes_totals
|
36
|
+
# concat item's sale_taxes
|
37
|
+
@taxes_totals = {}
|
38
|
+
taxes = lines&.map(&:sale_taxes)&.flatten
|
39
|
+
taxes&.each do |tax|
|
40
|
+
@taxes_totals[tax.keys.first] ||= 0
|
41
|
+
@taxes_totals[tax.keys.first] += tax.values.sum
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def get_total_code(tax)
|
46
|
+
return unless tax
|
47
|
+
case tax.tax_type
|
48
|
+
# TODO: :isc
|
49
|
+
when :igv
|
50
|
+
get_total_igv_code(tax.tax_exemption_reason)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def get_total_igv_code(exemption_reason)
|
55
|
+
if Catalogs::CATALOG_07.first == exemption_reason
|
56
|
+
Catalogs::CATALOG_14.first
|
57
|
+
elsif Catalogs::CATALOG_07[1..6].include?(exemption_reason)
|
58
|
+
Catalogs::CATALOG_14[3]
|
59
|
+
elsif Catalogs::CATALOG_07[7] == exemption_reason
|
60
|
+
Catalogs::CATALOG_14[2]
|
61
|
+
elsif Catalogs::CATALOG_07[8] == exemption_reason
|
62
|
+
Catalogs::CATALOG_14[1]
|
63
|
+
elsif Catalogs::CATALOG_07[9..14].include?(exemption_reason)
|
64
|
+
Catalogs::CATALOG_14[3]
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: false
|
2
|
+
|
3
|
+
require_relative 'tax'
|
4
|
+
require_relative 'trade_calculations'
|
5
|
+
|
6
|
+
module SunatInvoice
|
7
|
+
class TradeDocument < XmlDocument
|
8
|
+
include TradeCalculations
|
9
|
+
|
10
|
+
attr_accessor :customer, :document_number, :document_type, :discount
|
11
|
+
|
12
|
+
INVOICE_TYPES = %w[01 03].freeze
|
13
|
+
|
14
|
+
def operation
|
15
|
+
:send_bill
|
16
|
+
end
|
17
|
+
|
18
|
+
def document_name
|
19
|
+
"#{@provider.ruc}-#{document_type}-#{document_number}"
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def invoice?
|
25
|
+
INVOICE_TYPES.include?(document_type)
|
26
|
+
end
|
27
|
+
|
28
|
+
def build_document_data(xml)
|
29
|
+
build_number(xml)
|
30
|
+
xml['cbc'].IssueDate formatted_date(date)
|
31
|
+
xml['cbc'].InvoiceTypeCode document_type if invoice?
|
32
|
+
xml['cbc'].DocumentCurrencyCode currency
|
33
|
+
end
|
34
|
+
|
35
|
+
def build_ext(xml)
|
36
|
+
super(xml) do |xml_|
|
37
|
+
build_sale_totals(xml_)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def build_sale_totals(xml)
|
42
|
+
prepare_totals
|
43
|
+
ubl_ext(xml) do
|
44
|
+
xml['sac'].AdditionalInformation do
|
45
|
+
@sale_totals&.each do |code, amount|
|
46
|
+
build_monetary_total(xml, code, amount)
|
47
|
+
end
|
48
|
+
build_monetary_total(xml, '2005', discount) if discount
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
def build_monetary_total(xml, code, amount)
|
54
|
+
xml['sac'].AdditionalMonetaryTotal do
|
55
|
+
xml['cbc'].ID code
|
56
|
+
amount_xml(xml['cbc'], 'PayableAmount', amount, @currency)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def build_common_content(xml)
|
61
|
+
@signature.signer_data(xml)
|
62
|
+
@provider.info(xml)
|
63
|
+
@customer.info(xml)
|
64
|
+
build_taxes_totals(xml)
|
65
|
+
build_total(xml)
|
66
|
+
build_lines_xml(xml)
|
67
|
+
end
|
68
|
+
|
69
|
+
def build_taxes_totals(xml)
|
70
|
+
@taxes_totals.each do |key, value|
|
71
|
+
SunatInvoice::Tax.new(tax_type: key, amount: value).xml(xml, @currency)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def total_tag
|
76
|
+
'LegalMonetaryTotal'
|
77
|
+
end
|
78
|
+
|
79
|
+
def build_total(xml)
|
80
|
+
xml['cac'].send(total_tag) do
|
81
|
+
amount_xml(xml['cbc'], 'PayableAmount', @total, @currency)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
data/lib/sunat_invoice/utils.rb
CHANGED
@@ -4,18 +4,57 @@ module SunatInvoice
|
|
4
4
|
module Utils
|
5
5
|
@namespace_path = 'urn:oasis:names:specification:ubl:schema:xsd'
|
6
6
|
@sunat_namespace_path = 'urn:sunat:names:specification:ubl:peru:schema:xsd'
|
7
|
+
@un_namespace_path = 'urn:un:unece:uncefact:data:specification'
|
7
8
|
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
9
|
+
COMMON_NAMESPACES = {
|
10
|
+
cac: "#{@namespace_path}:CommonAggregateComponents-2",
|
11
|
+
cbc: "#{@namespace_path}:CommonBasicComponents-2",
|
12
|
+
ds: 'http://www.w3.org/2000/09/xmldsig#',
|
13
|
+
ext: "#{@namespace_path}:CommonExtensionComponents-2",
|
14
|
+
sac: "#{@sunat_namespace_path}:SunatAggregateComponents-1",
|
15
|
+
xsi: 'http://www.w3.org/2001/XMLSchema-instance'
|
16
|
+
}.freeze
|
17
|
+
|
18
|
+
TRADE_NAMESPACES = {
|
19
|
+
'xmlns:cac' => COMMON_NAMESPACES[:cac],
|
20
|
+
'xmlns:cbc' => COMMON_NAMESPACES[:cbc],
|
12
21
|
'xmlns:ccts' => 'urn:un:unece:uncefact:documentation:2',
|
13
|
-
'xmlns:ds' =>
|
14
|
-
'xmlns:ext' =>
|
22
|
+
'xmlns:ds' => COMMON_NAMESPACES[:ds],
|
23
|
+
'xmlns:ext' => COMMON_NAMESPACES[:ext],
|
15
24
|
'xmlns:qdt' => "#{@namespace_path}:QualifiedDatatypes-2",
|
16
|
-
'xmlns:sac' =>
|
17
|
-
'xmlns:udt' =>
|
18
|
-
'xmlns:xsi' =>
|
25
|
+
'xmlns:sac' => COMMON_NAMESPACES[:sac],
|
26
|
+
'xmlns:udt' => "#{@un_namespace_path}:UnqualifiedDataTypesSchemaModule:2",
|
27
|
+
'xmlns:xsi' => COMMON_NAMESPACES[:xsi]
|
28
|
+
}.freeze
|
29
|
+
|
30
|
+
INVOICE_NAMESPACES = {
|
31
|
+
'xmlns' => "#{@namespace_path}:Invoice-2"
|
32
|
+
}.freeze
|
33
|
+
|
34
|
+
CREDIT_NOTE_NAMESPACES = {
|
35
|
+
'xmlns' => "#{@namespace_path}:CreditNote-2"
|
36
|
+
}.freeze
|
37
|
+
|
38
|
+
DEBIT_NOTE_NAMESPACES = {
|
39
|
+
'xmlns' => "#{@namespace_path}:DebitNote-2"
|
40
|
+
}.freeze
|
41
|
+
|
42
|
+
SUMMARY_NAMESPACES = {
|
43
|
+
'xmlns:cac' => COMMON_NAMESPACES[:cac],
|
44
|
+
'xmlns:cbc' => COMMON_NAMESPACES[:cbc],
|
45
|
+
'xmlns:ds' => COMMON_NAMESPACES[:ds],
|
46
|
+
'xmlns:ext' => COMMON_NAMESPACES[:ext],
|
47
|
+
'xmlns:sac' => COMMON_NAMESPACES[:sac],
|
48
|
+
'xmlns:xsi' => COMMON_NAMESPACES[:xsi]
|
49
|
+
}.freeze
|
50
|
+
|
51
|
+
DAILY_SUMMARY_NAMESPACES = {
|
52
|
+
'xmlns' => "#{@sunat_namespace_path}:SummaryDocuments-1",
|
53
|
+
'xsi:schemaLocation' => 'urn:sunat:names:specification:ubl:peru:schema:xsd:InvoiceSummary-1 D:\UBL_SUNAT\SUNAT_xml_20110112\20110112\xsd\maindoc\UBLPE-InvoiceSummary-1.0.xsd'
|
54
|
+
}.freeze
|
55
|
+
|
56
|
+
VOIDED_NAMESPACES = {
|
57
|
+
'xmlns' => "#{@sunat_namespace_path}:VoidedDocuments-1"
|
19
58
|
}.freeze
|
20
59
|
|
21
60
|
def ubl_ext(xml, &block)
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: false
|
2
|
+
|
3
|
+
require_relative 'daily_summary'
|
4
|
+
|
5
|
+
module SunatInvoice
|
6
|
+
class Voided < DailySummary
|
7
|
+
private
|
8
|
+
|
9
|
+
def namespaces
|
10
|
+
VOIDED_NAMESPACES.merge(SUMMARY_NAMESPACES)
|
11
|
+
end
|
12
|
+
|
13
|
+
def root_name
|
14
|
+
'VoidedDocuments'
|
15
|
+
end
|
16
|
+
|
17
|
+
def document_number
|
18
|
+
formatted = date.strftime('%Y%m%d') # YYYYMMDD
|
19
|
+
"RA-#{formatted}-1"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'line'
|
4
|
+
|
5
|
+
module SunatInvoice
|
6
|
+
class VoidedLine < Line
|
7
|
+
attr_accessor :document_type, :document_serial, :document_number,
|
8
|
+
:description
|
9
|
+
|
10
|
+
def xml(xml, index, _currency)
|
11
|
+
xml['sac'].VoidedDocumentsLine do
|
12
|
+
xml['cbc'].LineID(index + 1)
|
13
|
+
xml['cbc'].DocumentTypeCode document_type
|
14
|
+
xml['sac'].DocumentSerialID document_serial
|
15
|
+
xml['sac'].DocumentNumberID document_number
|
16
|
+
xml['sac'].VoidReasonDescription description
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: false
|
2
|
+
|
3
|
+
require_relative 'model'
|
4
|
+
require_relative 'utils'
|
5
|
+
|
6
|
+
module SunatInvoice
|
7
|
+
class XmlDocument < Model
|
8
|
+
include Utils
|
9
|
+
|
10
|
+
attr_accessor :date, :provider, :signature, :currency, :lines
|
11
|
+
|
12
|
+
UBL_VERSION = '2.0'.freeze
|
13
|
+
CUSTOMIZATION = '1.0'.freeze
|
14
|
+
|
15
|
+
def initialize(*args)
|
16
|
+
super(*args)
|
17
|
+
@date ||= Date.today
|
18
|
+
end
|
19
|
+
|
20
|
+
def build_xml(&block)
|
21
|
+
Nokogiri::XML::Builder.new do |xml|
|
22
|
+
xml.send(root_name, namespaces) do
|
23
|
+
build_ext(xml)
|
24
|
+
xml['cbc'].UBLVersionID UBL_VERSION
|
25
|
+
xml['cbc'].CustomizationID CUSTOMIZATION
|
26
|
+
yield(xml) if block
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
def build_ext(xml, &block)
|
32
|
+
xml['ext'].UBLExtensions do
|
33
|
+
yield(xml) if block
|
34
|
+
@signature.signature_ext(xml)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def build_number(xml)
|
39
|
+
xml['cbc'].ID document_number
|
40
|
+
end
|
41
|
+
|
42
|
+
def formatted_date(date)
|
43
|
+
date.strftime('%Y-%m-%d')
|
44
|
+
end
|
45
|
+
|
46
|
+
def build_lines_xml(xml)
|
47
|
+
lines&.each_with_index do |line, index|
|
48
|
+
line.xml(xml, index, currency)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
data/lib/sunat_invoice.rb
CHANGED
@@ -1,13 +1,27 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# documents
|
4
|
+
require 'sunat_invoice/xml_document'
|
3
5
|
require 'sunat_invoice/invoice'
|
4
6
|
require 'sunat_invoice/provider'
|
5
7
|
require 'sunat_invoice/customer'
|
6
8
|
require 'sunat_invoice/item'
|
7
9
|
require 'sunat_invoice/tax'
|
10
|
+
require 'sunat_invoice/daily_summary'
|
11
|
+
require 'sunat_invoice/summary_line'
|
12
|
+
require 'sunat_invoice/voided'
|
13
|
+
require 'sunat_invoice/voided_line'
|
14
|
+
require 'sunat_invoice/credit_note'
|
15
|
+
require 'sunat_invoice/credit_note_line'
|
16
|
+
require 'sunat_invoice/debit_note'
|
17
|
+
require 'sunat_invoice/debit_note_line'
|
18
|
+
|
19
|
+
# clients
|
8
20
|
require 'sunat_invoice/configuration'
|
21
|
+
require 'sunat_invoice/response_parser'
|
9
22
|
require 'sunat_invoice/client'
|
10
23
|
require 'sunat_invoice/clients/invoice_client'
|
24
|
+
require 'sunat_invoice/clients/consult_client'
|
11
25
|
|
12
26
|
module SunatInvoice
|
13
27
|
class << self
|
data/sunat_invoice.gemspec
CHANGED
@@ -2,9 +2,9 @@
|
|
2
2
|
|
3
3
|
Gem::Specification.new do |s|
|
4
4
|
s.name = 'sunat_invoice'
|
5
|
-
s.version = '0.0.
|
6
|
-
s.summary = '
|
7
|
-
s.description =
|
5
|
+
s.version = '0.0.3'
|
6
|
+
s.summary = 'Ruby gem to use SUNAT Electronic Billing from your app'
|
7
|
+
s.description = 'Generate and send Electronic Invoices to SUNAT'
|
8
8
|
s.authors = ['César Carruitero']
|
9
9
|
s.email = ['cesar@mozilla.pe']
|
10
10
|
s.homepage = 'https://github.com/ccarruitero/sunat_invoice'
|
@@ -12,9 +12,9 @@ Gem::Specification.new do |s|
|
|
12
12
|
|
13
13
|
s.files = `git ls-files`.split("\n")
|
14
14
|
|
15
|
-
s.add_dependency 'savon', '~> 2.11.2'
|
16
15
|
s.add_dependency 'nokogiri', '~> 1.8'
|
17
|
-
s.add_dependency 'rubyzip', '~> 1.2
|
16
|
+
s.add_dependency 'rubyzip', '~> 1.2'
|
17
|
+
s.add_dependency 'savon', '~> 2.11'
|
18
18
|
s.add_dependency 'xmldsig', '~> 0.6.5'
|
19
19
|
|
20
20
|
s.add_development_dependency 'cutest', '~> 1.2'
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: false
|
2
|
+
|
3
|
+
require_relative 'helper'
|
4
|
+
|
5
|
+
setup do
|
6
|
+
provider = FactoryBot.build(:provider)
|
7
|
+
signature = FactoryBot.build(:signature, provider: provider)
|
8
|
+
note = FactoryBot.build(:credit_note, provider: provider,
|
9
|
+
signature: signature)
|
10
|
+
@parsed_xml = Nokogiri::XML(note.xml, &:noblanks)
|
11
|
+
end
|
12
|
+
|
13
|
+
test 'start with CreditNote tag' do
|
14
|
+
assert_equal @parsed_xml.root.name, 'CreditNote'
|
15
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
# frozen_string_literal: false
|
2
|
+
|
3
|
+
require_relative 'helper'
|
4
|
+
|
5
|
+
setup do
|
6
|
+
provider = FactoryBot.build(:provider)
|
7
|
+
signature = FactoryBot.build(:signature, provider: provider)
|
8
|
+
ref_date = Date.today - 1
|
9
|
+
@line = FactoryBot.build(:summary_line)
|
10
|
+
summary = SunatInvoice::DailySummary.new(provider: provider,
|
11
|
+
signature: signature,
|
12
|
+
reference_date: ref_date,
|
13
|
+
currency: 'PEN',
|
14
|
+
lines: [@line])
|
15
|
+
@parsed_xml = Nokogiri::XML(summary.xml, &:noblanks)
|
16
|
+
end
|
17
|
+
|
18
|
+
test 'start with SummaryDocuments tag' do
|
19
|
+
assert_equal @parsed_xml.root.name, 'SummaryDocuments'
|
20
|
+
end
|
21
|
+
|
22
|
+
test 'line total amount has correct content' do
|
23
|
+
total = @parsed_xml.at('//sac:SummaryDocumentsLine/sac:TotalAmount').content
|
24
|
+
assert_equal total, @line.total_amount.to_s
|
25
|
+
end
|
26
|
+
|
27
|
+
test 'ChargeIndicator must not be empty when not charge_type' do
|
28
|
+
assert @line.charge_type.nil?
|
29
|
+
assert !@parsed_xml.at('//cbc:ChargeIndicator').content.empty?
|
30
|
+
end
|
31
|
+
|
32
|
+
test 'AllowanceCharge Amount is 0' do
|
33
|
+
amount = @parsed_xml.at('//cac:AllowanceCharge/cbc:Amount').content
|
34
|
+
assert_equal amount, '0.01'
|
35
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: false
|
2
|
+
|
3
|
+
require_relative 'helper'
|
4
|
+
|
5
|
+
setup do
|
6
|
+
provider = FactoryBot.build(:provider)
|
7
|
+
signature = FactoryBot.build(:signature, provider: provider)
|
8
|
+
note = FactoryBot.build(:debit_note, provider: provider,
|
9
|
+
signature: signature)
|
10
|
+
@parsed_xml = Nokogiri::XML(note.xml, &:noblanks)
|
11
|
+
end
|
12
|
+
|
13
|
+
test 'start with DebitNote tag' do
|
14
|
+
assert_equal @parsed_xml.root.name, 'DebitNote'
|
15
|
+
end
|