zugpferd 0.1.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/lib/zugpferd/cii/mapping.rb +153 -0
- data/lib/zugpferd/cii/reader.rb +219 -0
- data/lib/zugpferd/cii/writer.rb +314 -0
- data/lib/zugpferd/model/allowance_charge.rb +48 -0
- data/lib/zugpferd/model/contact.rb +16 -0
- data/lib/zugpferd/model/invoice.rb +51 -0
- data/lib/zugpferd/model/item.rb +23 -0
- data/lib/zugpferd/model/line_item.rb +32 -0
- data/lib/zugpferd/model/monetary_totals.rb +35 -0
- data/lib/zugpferd/model/payment_instructions.rb +29 -0
- data/lib/zugpferd/model/postal_address.rb +19 -0
- data/lib/zugpferd/model/price.rb +19 -0
- data/lib/zugpferd/model/tax_breakdown.rb +23 -0
- data/lib/zugpferd/model/tax_subtotal.rb +32 -0
- data/lib/zugpferd/model/trade_party.rb +32 -0
- data/lib/zugpferd/ubl/mapping.rb +135 -0
- data/lib/zugpferd/ubl/reader.rb +218 -0
- data/lib/zugpferd/ubl/writer.rb +286 -0
- data/lib/zugpferd.rb +20 -0
- metadata +119 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1bafe4631b8105289e0f6c6efba696b91e0502cfbf99173918dc0d57de096263
|
|
4
|
+
data.tar.gz: 3afe5b0d992a4622f3eadfabfbc1b4495ff5deca092f15eb659b5a8b4b529f23
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8c50bedc1c561b09d28fb69c808dcc4b0e80b655e9a2c69a138229f05752ff4898ac64fd6bf144a4eb3d0cfdcfa84742576a64565097a7ae9f4e666c686fb06e
|
|
7
|
+
data.tar.gz: ad2b0b0bc923f4debd510f40a9e1b296212af4fcbff0df9af0df5c295b2608e01430c80993000257de81d8d247c30939143567d9a8efa80805f02028dd36c99e
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module Zugpferd
|
|
2
|
+
module CII
|
|
3
|
+
module Mapping
|
|
4
|
+
NS = {
|
|
5
|
+
"rsm" => "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
|
|
6
|
+
"ram" => "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100",
|
|
7
|
+
"udt" => "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100",
|
|
8
|
+
"qdt" => "urn:un:unece:uncefact:data:standard:QualifiedDataType:100",
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
# Document context
|
|
12
|
+
CONTEXT = "rsm:ExchangedDocumentContext"
|
|
13
|
+
DOCUMENT = "rsm:ExchangedDocument"
|
|
14
|
+
TRANSACTION = "rsm:SupplyChainTradeTransaction"
|
|
15
|
+
|
|
16
|
+
# Invoice header (BG-0)
|
|
17
|
+
INVOICE = {
|
|
18
|
+
number: "#{DOCUMENT}/ram:ID",
|
|
19
|
+
issue_date: "#{DOCUMENT}/ram:IssueDateTime/udt:DateTimeString",
|
|
20
|
+
type_code: "#{DOCUMENT}/ram:TypeCode",
|
|
21
|
+
note: "#{DOCUMENT}/ram:IncludedNote/ram:Content",
|
|
22
|
+
customization_id: "#{CONTEXT}/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID",
|
|
23
|
+
profile_id: "#{CONTEXT}/ram:BusinessProcessSpecifiedDocumentContextParameter/ram:ID",
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Settlement (contains currency, payment, tax, totals)
|
|
27
|
+
SETTLEMENT = "#{TRANSACTION}/ram:ApplicableHeaderTradeSettlement"
|
|
28
|
+
AGREEMENT = "#{TRANSACTION}/ram:ApplicableHeaderTradeAgreement"
|
|
29
|
+
|
|
30
|
+
INVOICE_SETTLEMENT = {
|
|
31
|
+
currency_code: "#{SETTLEMENT}/ram:InvoiceCurrencyCode",
|
|
32
|
+
buyer_reference: "#{AGREEMENT}/ram:BuyerReference",
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# Seller (BG-4)
|
|
36
|
+
SELLER = "#{AGREEMENT}/ram:SellerTradeParty"
|
|
37
|
+
# Buyer (BG-7)
|
|
38
|
+
BUYER = "#{AGREEMENT}/ram:BuyerTradeParty"
|
|
39
|
+
|
|
40
|
+
# TradeParty fields
|
|
41
|
+
PARTY = {
|
|
42
|
+
name: "ram:Name",
|
|
43
|
+
trading_name: "ram:SpecifiedLegalOrganization/ram:TradingBusinessName",
|
|
44
|
+
identifier: "ram:ID",
|
|
45
|
+
legal_registration_id: "ram:SpecifiedLegalOrganization/ram:ID",
|
|
46
|
+
legal_form: "ram:Description",
|
|
47
|
+
vat_identifier: "ram:SpecifiedTaxRegistration/ram:ID[@schemeID='VA']",
|
|
48
|
+
electronic_address: "ram:URIUniversalCommunication/ram:URIID",
|
|
49
|
+
}.freeze
|
|
50
|
+
|
|
51
|
+
# PostalAddress (BG-5 / BG-8)
|
|
52
|
+
POSTAL_ADDRESS = "ram:PostalTradeAddress"
|
|
53
|
+
ADDRESS = {
|
|
54
|
+
street_name: "ram:LineOne",
|
|
55
|
+
city_name: "ram:CityName",
|
|
56
|
+
postal_zone: "ram:PostcodeCode",
|
|
57
|
+
country_code: "ram:CountryID",
|
|
58
|
+
}.freeze
|
|
59
|
+
|
|
60
|
+
# Contact (BG-6 / BG-9)
|
|
61
|
+
CONTACT = "ram:DefinedTradeContact"
|
|
62
|
+
CONTACT_FIELDS = {
|
|
63
|
+
name: "ram:PersonName",
|
|
64
|
+
telephone: "ram:TelephoneUniversalCommunication/ram:CompleteNumber",
|
|
65
|
+
email: "ram:EmailURIUniversalCommunication/ram:URIID",
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
# PaymentMeans (BG-16)
|
|
69
|
+
PAYMENT_MEANS = "ram:SpecifiedTradeSettlementPaymentMeans"
|
|
70
|
+
PAYMENT = {
|
|
71
|
+
payment_means_code: "ram:TypeCode",
|
|
72
|
+
account_id: "ram:PayeePartyCreditorFinancialAccount/ram:IBANID",
|
|
73
|
+
card_account_id: "ram:ApplicableTradeSettlementFinancialCard/ram:ID",
|
|
74
|
+
card_holder_name: "ram:ApplicableTradeSettlementFinancialCard/ram:CardholderName",
|
|
75
|
+
debited_account_id: "ram:PayerPartyDebtorFinancialAccount/ram:IBANID",
|
|
76
|
+
}.freeze
|
|
77
|
+
CREDITOR_REFERENCE_ID = "ram:CreditorReferenceID"
|
|
78
|
+
PAYMENT_REFERENCE = "ram:PaymentReference"
|
|
79
|
+
PAYMENT_TERMS_NOTE = "ram:SpecifiedTradePaymentTerms/ram:Description"
|
|
80
|
+
PAYMENT_TERMS_DUE_DATE = "ram:SpecifiedTradePaymentTerms/ram:DueDateDateTime/udt:DateTimeString"
|
|
81
|
+
PAYMENT_TERMS_MANDATE = "ram:SpecifiedTradePaymentTerms/ram:DirectDebitMandateID"
|
|
82
|
+
|
|
83
|
+
# TaxTotal (BG-23)
|
|
84
|
+
TAX_SUBTOTAL = "ram:ApplicableTradeTax"
|
|
85
|
+
TAX = {
|
|
86
|
+
taxable_amount: "ram:BasisAmount",
|
|
87
|
+
tax_amount: "ram:CalculatedAmount",
|
|
88
|
+
category_code: "ram:CategoryCode",
|
|
89
|
+
percent: "ram:RateApplicablePercent",
|
|
90
|
+
exemption_reason: "ram:ExemptionReason",
|
|
91
|
+
exemption_reason_code: "ram:ExemptionReasonCode",
|
|
92
|
+
}.freeze
|
|
93
|
+
|
|
94
|
+
# LegalMonetaryTotal (BG-22)
|
|
95
|
+
MONETARY_TOTAL = "ram:SpecifiedTradeSettlementHeaderMonetarySummation"
|
|
96
|
+
TOTALS = {
|
|
97
|
+
line_extension_amount: "ram:LineTotalAmount",
|
|
98
|
+
tax_exclusive_amount: "ram:TaxBasisTotalAmount",
|
|
99
|
+
tax_inclusive_amount: "ram:GrandTotalAmount",
|
|
100
|
+
prepaid_amount: "ram:TotalPrepaidAmount",
|
|
101
|
+
payable_rounding_amount: "ram:RoundingAmount",
|
|
102
|
+
payable_amount: "ram:DuePayableAmount",
|
|
103
|
+
tax_total_amount: "ram:TaxTotalAmount",
|
|
104
|
+
allowance_total_amount: "ram:AllowanceTotalAmount",
|
|
105
|
+
charge_total_amount: "ram:ChargeTotalAmount",
|
|
106
|
+
}.freeze
|
|
107
|
+
|
|
108
|
+
# AllowanceCharge (BG-20 / BG-21)
|
|
109
|
+
ALLOWANCE_CHARGE = "ram:SpecifiedTradeAllowanceCharge"
|
|
110
|
+
ALLOWANCE_CHARGE_FIELDS = {
|
|
111
|
+
charge_indicator: "ram:ChargeIndicator/udt:Indicator",
|
|
112
|
+
reason: "ram:Reason",
|
|
113
|
+
reason_code: "ram:ReasonCode",
|
|
114
|
+
amount: "ram:ActualAmount",
|
|
115
|
+
base_amount: "ram:BasisAmount",
|
|
116
|
+
multiplier_factor: "ram:CalculationPercent",
|
|
117
|
+
tax_category_code: "ram:CategoryTradeTax/ram:CategoryCode",
|
|
118
|
+
tax_percent: "ram:CategoryTradeTax/ram:RateApplicablePercent",
|
|
119
|
+
}.freeze
|
|
120
|
+
|
|
121
|
+
# InvoiceLine (BG-25)
|
|
122
|
+
INVOICE_LINE = "#{TRANSACTION}/ram:IncludedSupplyChainTradeLineItem"
|
|
123
|
+
LINE = {
|
|
124
|
+
id: "ram:AssociatedDocumentLineDocument/ram:LineID",
|
|
125
|
+
note: "ram:AssociatedDocumentLineDocument/ram:IncludedNote/ram:Content",
|
|
126
|
+
invoiced_quantity: "ram:SpecifiedLineTradeDelivery/ram:BilledQuantity",
|
|
127
|
+
unit_code: "ram:SpecifiedLineTradeDelivery/ram:BilledQuantity/@unitCode",
|
|
128
|
+
line_extension_amount: "ram:SpecifiedLineTradeSettlement/ram:SpecifiedTradeSettlementLineMonetarySummation/ram:LineTotalAmount",
|
|
129
|
+
}.freeze
|
|
130
|
+
|
|
131
|
+
# Item (BG-31)
|
|
132
|
+
ITEM = "ram:SpecifiedTradeProduct"
|
|
133
|
+
ITEM_FIELDS = {
|
|
134
|
+
name: "ram:Name",
|
|
135
|
+
description: "ram:Description",
|
|
136
|
+
sellers_identifier: "ram:SellerAssignedID",
|
|
137
|
+
}.freeze
|
|
138
|
+
|
|
139
|
+
# Item tax (from line settlement, not from product)
|
|
140
|
+
ITEM_TAX = "ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax"
|
|
141
|
+
ITEM_TAX_FIELDS = {
|
|
142
|
+
tax_category: "ram:CategoryCode",
|
|
143
|
+
tax_percent: "ram:RateApplicablePercent",
|
|
144
|
+
}.freeze
|
|
145
|
+
|
|
146
|
+
# Price (BG-29)
|
|
147
|
+
PRICE = "ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice"
|
|
148
|
+
PRICE_FIELDS = {
|
|
149
|
+
amount: "ram:ChargeAmount",
|
|
150
|
+
}.freeze
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
require "nokogiri"
|
|
2
|
+
require "date"
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require_relative "mapping"
|
|
5
|
+
|
|
6
|
+
module Zugpferd
|
|
7
|
+
module CII
|
|
8
|
+
# Reads UN/CEFACT CII CrossIndustryInvoice XML into {Model::Invoice}.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# invoice = Zugpferd::CII::Reader.new.read(File.read("invoice.xml"))
|
|
12
|
+
class Reader
|
|
13
|
+
include Mapping
|
|
14
|
+
|
|
15
|
+
# Parses a CII CrossIndustryInvoice XML string.
|
|
16
|
+
#
|
|
17
|
+
# @param xml_string [String] valid CII D16B XML
|
|
18
|
+
# @return [Model::Invoice]
|
|
19
|
+
# @raise [Nokogiri::XML::SyntaxError] if the XML is malformed
|
|
20
|
+
def read(xml_string)
|
|
21
|
+
doc = Nokogiri::XML(xml_string) { |config| config.strict }
|
|
22
|
+
root = doc.root
|
|
23
|
+
build_invoice(root)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def build_invoice(root)
|
|
29
|
+
settlement = root.at_xpath(SETTLEMENT, NS)
|
|
30
|
+
Model::Invoice.new(
|
|
31
|
+
number: text(root, INVOICE[:number]),
|
|
32
|
+
issue_date: parse_cii_date(text(root, INVOICE[:issue_date])),
|
|
33
|
+
due_date: parse_cii_date(settlement ? text(settlement, PAYMENT_TERMS_DUE_DATE) : nil),
|
|
34
|
+
type_code: text(root, INVOICE[:type_code]),
|
|
35
|
+
currency_code: text(root, INVOICE_SETTLEMENT[:currency_code]),
|
|
36
|
+
buyer_reference: text(root, INVOICE_SETTLEMENT[:buyer_reference]),
|
|
37
|
+
customization_id: text(root, INVOICE[:customization_id]),
|
|
38
|
+
profile_id: text(root, INVOICE[:profile_id]),
|
|
39
|
+
note: text(root, INVOICE[:note]),
|
|
40
|
+
seller: build_party(root.at_xpath(SELLER, NS)),
|
|
41
|
+
buyer: build_party(root.at_xpath(BUYER, NS)),
|
|
42
|
+
line_items: root.xpath(INVOICE_LINE, NS).map { |n| build_line_item(n) },
|
|
43
|
+
allowance_charges: settlement ? build_allowance_charges(settlement) : [],
|
|
44
|
+
tax_breakdown: build_tax_breakdown(settlement),
|
|
45
|
+
monetary_totals: build_monetary_totals(settlement&.at_xpath(MONETARY_TOTAL, NS)),
|
|
46
|
+
payment_instructions: build_payment_instructions(settlement),
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_party(node)
|
|
51
|
+
return nil unless node
|
|
52
|
+
|
|
53
|
+
party = Model::TradeParty.new(
|
|
54
|
+
name: text(node, PARTY[:name]),
|
|
55
|
+
trading_name: text(node, PARTY[:trading_name]),
|
|
56
|
+
identifier: text(node, PARTY[:identifier]),
|
|
57
|
+
legal_registration_id: text(node, PARTY[:legal_registration_id]),
|
|
58
|
+
legal_form: text(node, PARTY[:legal_form]),
|
|
59
|
+
vat_identifier: text(node, PARTY[:vat_identifier]),
|
|
60
|
+
electronic_address: text(node, PARTY[:electronic_address]),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
endpoint = node.at_xpath(PARTY[:electronic_address], NS)
|
|
64
|
+
party.electronic_address_scheme = endpoint["schemeID"] if endpoint
|
|
65
|
+
|
|
66
|
+
addr_node = node.at_xpath(POSTAL_ADDRESS, NS)
|
|
67
|
+
party.postal_address = build_postal_address(addr_node) if addr_node
|
|
68
|
+
|
|
69
|
+
contact_node = node.at_xpath(CONTACT, NS)
|
|
70
|
+
party.contact = build_contact(contact_node) if contact_node
|
|
71
|
+
|
|
72
|
+
party
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_postal_address(node)
|
|
76
|
+
Model::PostalAddress.new(
|
|
77
|
+
country_code: text(node, ADDRESS[:country_code]),
|
|
78
|
+
street_name: text(node, ADDRESS[:street_name]),
|
|
79
|
+
city_name: text(node, ADDRESS[:city_name]),
|
|
80
|
+
postal_zone: text(node, ADDRESS[:postal_zone]),
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def build_contact(node)
|
|
85
|
+
Model::Contact.new(
|
|
86
|
+
name: text(node, CONTACT_FIELDS[:name]),
|
|
87
|
+
telephone: text(node, CONTACT_FIELDS[:telephone]),
|
|
88
|
+
email: text(node, CONTACT_FIELDS[:email]),
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def build_payment_instructions(settlement_node)
|
|
93
|
+
return nil unless settlement_node
|
|
94
|
+
|
|
95
|
+
means_node = settlement_node.at_xpath(PAYMENT_MEANS, NS)
|
|
96
|
+
return nil unless means_node
|
|
97
|
+
|
|
98
|
+
Model::PaymentInstructions.new(
|
|
99
|
+
payment_means_code: text(means_node, PAYMENT[:payment_means_code]),
|
|
100
|
+
payment_id: text(settlement_node, PAYMENT_REFERENCE),
|
|
101
|
+
account_id: text(means_node, PAYMENT[:account_id]),
|
|
102
|
+
card_account_id: text(means_node, PAYMENT[:card_account_id]),
|
|
103
|
+
card_holder_name: text(means_node, PAYMENT[:card_holder_name]),
|
|
104
|
+
debited_account_id: text(means_node, PAYMENT[:debited_account_id]),
|
|
105
|
+
creditor_reference_id: text(settlement_node, CREDITOR_REFERENCE_ID),
|
|
106
|
+
mandate_reference: text(settlement_node, PAYMENT_TERMS_MANDATE),
|
|
107
|
+
note: text(settlement_node, PAYMENT_TERMS_NOTE),
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def build_tax_breakdown(settlement_node)
|
|
112
|
+
return nil unless settlement_node
|
|
113
|
+
|
|
114
|
+
totals_node = settlement_node.at_xpath(MONETARY_TOTAL, NS)
|
|
115
|
+
tax_total_node = totals_node&.at_xpath(TOTALS[:tax_total_amount], NS)
|
|
116
|
+
currency = tax_total_node&.[]("currencyID")
|
|
117
|
+
|
|
118
|
+
breakdown = Model::TaxBreakdown.new(
|
|
119
|
+
tax_amount: tax_total_node&.text,
|
|
120
|
+
currency_code: currency,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
breakdown.subtotals = settlement_node.xpath(TAX_SUBTOTAL, NS).map do |sub|
|
|
124
|
+
Model::TaxSubtotal.new(
|
|
125
|
+
taxable_amount: text(sub, TAX[:taxable_amount]),
|
|
126
|
+
tax_amount: text(sub, TAX[:tax_amount]),
|
|
127
|
+
category_code: text(sub, TAX[:category_code]),
|
|
128
|
+
percent: parse_decimal(text(sub, TAX[:percent])),
|
|
129
|
+
currency_code: currency,
|
|
130
|
+
exemption_reason: text(sub, TAX[:exemption_reason]),
|
|
131
|
+
exemption_reason_code: text(sub, TAX[:exemption_reason_code]),
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
breakdown
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def build_monetary_totals(node)
|
|
139
|
+
return nil unless node
|
|
140
|
+
|
|
141
|
+
Model::MonetaryTotals.new(
|
|
142
|
+
line_extension_amount: text(node, TOTALS[:line_extension_amount]),
|
|
143
|
+
tax_exclusive_amount: text(node, TOTALS[:tax_exclusive_amount]),
|
|
144
|
+
tax_inclusive_amount: text(node, TOTALS[:tax_inclusive_amount]),
|
|
145
|
+
prepaid_amount: parse_decimal(text(node, TOTALS[:prepaid_amount])),
|
|
146
|
+
payable_rounding_amount: parse_decimal(text(node, TOTALS[:payable_rounding_amount])),
|
|
147
|
+
allowance_total_amount: parse_decimal(text(node, TOTALS[:allowance_total_amount])),
|
|
148
|
+
charge_total_amount: parse_decimal(text(node, TOTALS[:charge_total_amount])),
|
|
149
|
+
payable_amount: text(node, TOTALS[:payable_amount]),
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_line_item(node)
|
|
154
|
+
item_node = node.at_xpath(ITEM, NS)
|
|
155
|
+
price_node = node.at_xpath(PRICE, NS)
|
|
156
|
+
tax_node = node.at_xpath(ITEM_TAX, NS)
|
|
157
|
+
|
|
158
|
+
Model::LineItem.new(
|
|
159
|
+
id: text(node, LINE[:id]),
|
|
160
|
+
invoiced_quantity: text(node, LINE[:invoiced_quantity]),
|
|
161
|
+
unit_code: node.at_xpath(LINE[:unit_code], NS)&.text,
|
|
162
|
+
line_extension_amount: text(node, LINE[:line_extension_amount]),
|
|
163
|
+
note: text(node, LINE[:note]),
|
|
164
|
+
item: build_item(item_node, tax_node),
|
|
165
|
+
price: build_price(price_node),
|
|
166
|
+
)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def build_item(node, tax_node)
|
|
170
|
+
return nil unless node
|
|
171
|
+
|
|
172
|
+
Model::Item.new(
|
|
173
|
+
name: text(node, ITEM_FIELDS[:name]),
|
|
174
|
+
description: text(node, ITEM_FIELDS[:description]),
|
|
175
|
+
sellers_identifier: text(node, ITEM_FIELDS[:sellers_identifier]),
|
|
176
|
+
tax_category: tax_node ? text(tax_node, ITEM_TAX_FIELDS[:tax_category]) : nil,
|
|
177
|
+
tax_percent: tax_node ? parse_decimal(text(tax_node, ITEM_TAX_FIELDS[:tax_percent])) : nil,
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def build_price(node)
|
|
182
|
+
return nil unless node
|
|
183
|
+
|
|
184
|
+
Model::Price.new(
|
|
185
|
+
amount: text(node, PRICE_FIELDS[:amount]),
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def build_allowance_charges(settlement_node)
|
|
190
|
+
settlement_node.xpath(ALLOWANCE_CHARGE, NS).map do |node|
|
|
191
|
+
Model::AllowanceCharge.new(
|
|
192
|
+
charge_indicator: text(node, ALLOWANCE_CHARGE_FIELDS[:charge_indicator]) == "true",
|
|
193
|
+
reason: text(node, ALLOWANCE_CHARGE_FIELDS[:reason]),
|
|
194
|
+
reason_code: text(node, ALLOWANCE_CHARGE_FIELDS[:reason_code]),
|
|
195
|
+
amount: text(node, ALLOWANCE_CHARGE_FIELDS[:amount]),
|
|
196
|
+
base_amount: parse_decimal(text(node, ALLOWANCE_CHARGE_FIELDS[:base_amount])),
|
|
197
|
+
multiplier_factor: parse_decimal(text(node, ALLOWANCE_CHARGE_FIELDS[:multiplier_factor])),
|
|
198
|
+
tax_category_code: text(node, ALLOWANCE_CHARGE_FIELDS[:tax_category_code]),
|
|
199
|
+
tax_percent: parse_decimal(text(node, ALLOWANCE_CHARGE_FIELDS[:tax_percent])),
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def text(node, xpath)
|
|
205
|
+
node.at_xpath(xpath, NS)&.text
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def parse_cii_date(str)
|
|
209
|
+
return nil unless str
|
|
210
|
+
# CII format 102 = YYYYMMDD
|
|
211
|
+
Date.strptime(str, "%Y%m%d")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def parse_decimal(str)
|
|
215
|
+
BigDecimal(str) if str
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
require "nokogiri"
|
|
2
|
+
require_relative "mapping"
|
|
3
|
+
|
|
4
|
+
module Zugpferd
|
|
5
|
+
module CII
|
|
6
|
+
# Writes {Model::Invoice} to UN/CEFACT CII CrossIndustryInvoice XML.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# xml = Zugpferd::CII::Writer.new.write(invoice)
|
|
10
|
+
class Writer
|
|
11
|
+
include Mapping
|
|
12
|
+
|
|
13
|
+
# Serializes an invoice to CII D16B XML.
|
|
14
|
+
#
|
|
15
|
+
# @param invoice [Model::Invoice] the invoice to serialize
|
|
16
|
+
# @return [String] UTF-8 encoded XML string
|
|
17
|
+
def write(invoice)
|
|
18
|
+
builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
|
|
19
|
+
xml["rsm"].CrossIndustryInvoice(
|
|
20
|
+
"xmlns:rsm" => NS["rsm"],
|
|
21
|
+
"xmlns:ram" => NS["ram"],
|
|
22
|
+
"xmlns:qdt" => NS["qdt"],
|
|
23
|
+
"xmlns:udt" => NS["udt"]
|
|
24
|
+
) do
|
|
25
|
+
build_document_context(xml, invoice)
|
|
26
|
+
build_exchanged_document(xml, invoice)
|
|
27
|
+
build_transaction(xml, invoice)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
builder.to_xml
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def build_document_context(xml, inv)
|
|
36
|
+
xml["rsm"].ExchangedDocumentContext do
|
|
37
|
+
if inv.profile_id
|
|
38
|
+
xml["ram"].BusinessProcessSpecifiedDocumentContextParameter do
|
|
39
|
+
xml["ram"].ID inv.profile_id
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
if inv.customization_id
|
|
43
|
+
xml["ram"].GuidelineSpecifiedDocumentContextParameter do
|
|
44
|
+
xml["ram"].ID inv.customization_id
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def build_exchanged_document(xml, inv)
|
|
51
|
+
xml["rsm"].ExchangedDocument do
|
|
52
|
+
xml["ram"].ID inv.number
|
|
53
|
+
xml["ram"].TypeCode inv.type_code
|
|
54
|
+
xml["ram"].IssueDateTime do
|
|
55
|
+
xml["udt"].DateTimeString(format_cii_date(inv.issue_date), format: "102")
|
|
56
|
+
end
|
|
57
|
+
if inv.note
|
|
58
|
+
xml["ram"].IncludedNote do
|
|
59
|
+
xml["ram"].Content inv.note
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def build_transaction(xml, inv)
|
|
66
|
+
xml["rsm"].SupplyChainTradeTransaction do
|
|
67
|
+
inv.line_items.each { |li| build_line_item(xml, li) }
|
|
68
|
+
build_agreement(xml, inv)
|
|
69
|
+
xml["ram"].ApplicableHeaderTradeDelivery
|
|
70
|
+
build_settlement(xml, inv)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_agreement(xml, inv)
|
|
75
|
+
xml["ram"].ApplicableHeaderTradeAgreement do
|
|
76
|
+
xml["ram"].BuyerReference inv.buyer_reference if inv.buyer_reference
|
|
77
|
+
build_party(xml, "SellerTradeParty", inv.seller) if inv.seller
|
|
78
|
+
build_party(xml, "BuyerTradeParty", inv.buyer) if inv.buyer
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def build_party(xml, element_name, party)
|
|
83
|
+
xml["ram"].send(element_name) do
|
|
84
|
+
if party.identifier
|
|
85
|
+
xml["ram"].ID party.identifier
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
xml["ram"].Name party.name
|
|
89
|
+
|
|
90
|
+
xml["ram"].Description party.legal_form if party.legal_form
|
|
91
|
+
|
|
92
|
+
if party.legal_registration_id || party.trading_name
|
|
93
|
+
xml["ram"].SpecifiedLegalOrganization do
|
|
94
|
+
xml["ram"].ID party.legal_registration_id if party.legal_registration_id
|
|
95
|
+
xml["ram"].TradingBusinessName party.trading_name if party.trading_name
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
build_contact(xml, party.contact) if party.contact
|
|
100
|
+
|
|
101
|
+
build_postal_address(xml, party.postal_address) if party.postal_address
|
|
102
|
+
|
|
103
|
+
if party.electronic_address
|
|
104
|
+
xml["ram"].URIUniversalCommunication do
|
|
105
|
+
attrs = {}
|
|
106
|
+
attrs[:schemeID] = party.electronic_address_scheme if party.electronic_address_scheme
|
|
107
|
+
xml["ram"].URIID(party.electronic_address, attrs)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if party.vat_identifier
|
|
112
|
+
xml["ram"].SpecifiedTaxRegistration do
|
|
113
|
+
xml["ram"].ID(party.vat_identifier, schemeID: "VA")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def build_postal_address(xml, addr)
|
|
120
|
+
xml["ram"].PostalTradeAddress do
|
|
121
|
+
xml["ram"].PostcodeCode addr.postal_zone if addr.postal_zone
|
|
122
|
+
xml["ram"].LineOne addr.street_name if addr.street_name
|
|
123
|
+
xml["ram"].CityName addr.city_name if addr.city_name
|
|
124
|
+
xml["ram"].CountryID addr.country_code
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_contact(xml, contact)
|
|
129
|
+
xml["ram"].DefinedTradeContact do
|
|
130
|
+
xml["ram"].PersonName contact.name if contact.name
|
|
131
|
+
if contact.telephone
|
|
132
|
+
xml["ram"].TelephoneUniversalCommunication do
|
|
133
|
+
xml["ram"].CompleteNumber contact.telephone
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
if contact.email
|
|
137
|
+
xml["ram"].EmailURIUniversalCommunication do
|
|
138
|
+
xml["ram"].URIID contact.email
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def build_settlement(xml, inv)
|
|
145
|
+
xml["ram"].ApplicableHeaderTradeSettlement do
|
|
146
|
+
if inv.payment_instructions&.creditor_reference_id
|
|
147
|
+
xml["ram"].CreditorReferenceID inv.payment_instructions.creditor_reference_id
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if inv.payment_instructions&.payment_id
|
|
151
|
+
xml["ram"].PaymentReference inv.payment_instructions.payment_id
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
xml["ram"].InvoiceCurrencyCode inv.currency_code
|
|
155
|
+
|
|
156
|
+
build_payment_means(xml, inv.payment_instructions) if inv.payment_instructions
|
|
157
|
+
|
|
158
|
+
if inv.tax_breakdown
|
|
159
|
+
inv.tax_breakdown.subtotals.each do |sub|
|
|
160
|
+
build_tax_subtotal(xml, sub)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
inv.allowance_charges.each { |ac| build_allowance_charge(xml, ac) }
|
|
165
|
+
|
|
166
|
+
if inv.payment_instructions&.note || inv.due_date || inv.payment_instructions&.mandate_reference
|
|
167
|
+
xml["ram"].SpecifiedTradePaymentTerms do
|
|
168
|
+
xml["ram"].Description inv.payment_instructions.note if inv.payment_instructions&.note
|
|
169
|
+
if inv.due_date
|
|
170
|
+
xml["ram"].DueDateDateTime do
|
|
171
|
+
xml["udt"].DateTimeString(format_cii_date(inv.due_date), format: "102")
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
xml["ram"].DirectDebitMandateID inv.payment_instructions.mandate_reference if inv.payment_instructions&.mandate_reference
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
build_monetary_total(xml, inv.monetary_totals, inv.tax_breakdown) if inv.monetary_totals
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def build_payment_means(xml, payment)
|
|
183
|
+
xml["ram"].SpecifiedTradeSettlementPaymentMeans do
|
|
184
|
+
xml["ram"].TypeCode payment.payment_means_code
|
|
185
|
+
if payment.card_account_id
|
|
186
|
+
xml["ram"].ApplicableTradeSettlementFinancialCard do
|
|
187
|
+
xml["ram"].ID payment.card_account_id
|
|
188
|
+
xml["ram"].CardholderName payment.card_holder_name if payment.card_holder_name
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
if payment.debited_account_id
|
|
192
|
+
xml["ram"].PayerPartyDebtorFinancialAccount do
|
|
193
|
+
xml["ram"].IBANID payment.debited_account_id
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
if payment.account_id
|
|
197
|
+
xml["ram"].PayeePartyCreditorFinancialAccount do
|
|
198
|
+
xml["ram"].IBANID payment.account_id
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def build_tax_subtotal(xml, sub)
|
|
205
|
+
xml["ram"].ApplicableTradeTax do
|
|
206
|
+
xml["ram"].CalculatedAmount format_decimal(sub.tax_amount)
|
|
207
|
+
xml["ram"].TypeCode "VAT"
|
|
208
|
+
xml["ram"].ExemptionReason sub.exemption_reason if sub.exemption_reason
|
|
209
|
+
xml["ram"].BasisAmount format_decimal(sub.taxable_amount)
|
|
210
|
+
xml["ram"].CategoryCode sub.category_code
|
|
211
|
+
xml["ram"].ExemptionReasonCode sub.exemption_reason_code if sub.exemption_reason_code
|
|
212
|
+
xml["ram"].RateApplicablePercent format_decimal(sub.percent) if sub.percent
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def build_monetary_total(xml, totals, tax_breakdown)
|
|
217
|
+
xml["ram"].SpecifiedTradeSettlementHeaderMonetarySummation do
|
|
218
|
+
xml["ram"].LineTotalAmount format_decimal(totals.line_extension_amount)
|
|
219
|
+
xml["ram"].ChargeTotalAmount format_decimal(totals.charge_total_amount) if totals.charge_total_amount
|
|
220
|
+
xml["ram"].AllowanceTotalAmount format_decimal(totals.allowance_total_amount) if totals.allowance_total_amount
|
|
221
|
+
xml["ram"].TaxBasisTotalAmount format_decimal(totals.tax_exclusive_amount)
|
|
222
|
+
if tax_breakdown
|
|
223
|
+
xml["ram"].TaxTotalAmount(format_decimal(tax_breakdown.tax_amount),
|
|
224
|
+
currencyID: tax_breakdown.currency_code)
|
|
225
|
+
end
|
|
226
|
+
xml["ram"].GrandTotalAmount format_decimal(totals.tax_inclusive_amount)
|
|
227
|
+
xml["ram"].RoundingAmount format_decimal(totals.payable_rounding_amount) if totals.payable_rounding_amount
|
|
228
|
+
xml["ram"].TotalPrepaidAmount format_decimal(totals.prepaid_amount) if totals.prepaid_amount
|
|
229
|
+
xml["ram"].DuePayableAmount format_decimal(totals.payable_amount)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def build_allowance_charge(xml, ac)
|
|
234
|
+
xml["ram"].SpecifiedTradeAllowanceCharge do
|
|
235
|
+
xml["ram"].ChargeIndicator do
|
|
236
|
+
xml["udt"].Indicator ac.charge_indicator.to_s
|
|
237
|
+
end
|
|
238
|
+
xml["ram"].CalculationPercent format_decimal(ac.multiplier_factor) if ac.multiplier_factor
|
|
239
|
+
xml["ram"].BasisAmount format_decimal(ac.base_amount) if ac.base_amount
|
|
240
|
+
xml["ram"].ActualAmount format_decimal(ac.amount)
|
|
241
|
+
xml["ram"].ReasonCode ac.reason_code if ac.reason_code
|
|
242
|
+
xml["ram"].Reason ac.reason if ac.reason
|
|
243
|
+
if ac.tax_category_code
|
|
244
|
+
xml["ram"].CategoryTradeTax do
|
|
245
|
+
xml["ram"].TypeCode "VAT"
|
|
246
|
+
xml["ram"].CategoryCode ac.tax_category_code
|
|
247
|
+
xml["ram"].RateApplicablePercent format_decimal(ac.tax_percent) if ac.tax_percent
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def build_line_item(xml, line)
|
|
254
|
+
xml["ram"].IncludedSupplyChainTradeLineItem do
|
|
255
|
+
xml["ram"].AssociatedDocumentLineDocument do
|
|
256
|
+
xml["ram"].LineID line.id
|
|
257
|
+
if line.note
|
|
258
|
+
xml["ram"].IncludedNote do
|
|
259
|
+
xml["ram"].Content line.note
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
build_item(xml, line.item) if line.item
|
|
265
|
+
|
|
266
|
+
xml["ram"].SpecifiedLineTradeAgreement do
|
|
267
|
+
if line.price
|
|
268
|
+
xml["ram"].NetPriceProductTradePrice do
|
|
269
|
+
xml["ram"].ChargeAmount format_decimal(line.price.amount)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
xml["ram"].SpecifiedLineTradeDelivery do
|
|
275
|
+
xml["ram"].BilledQuantity(format_decimal(line.invoiced_quantity),
|
|
276
|
+
unitCode: line.unit_code)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
xml["ram"].SpecifiedLineTradeSettlement do
|
|
280
|
+
if line.item&.tax_category
|
|
281
|
+
xml["ram"].ApplicableTradeTax do
|
|
282
|
+
xml["ram"].TypeCode "VAT"
|
|
283
|
+
xml["ram"].CategoryCode line.item.tax_category
|
|
284
|
+
xml["ram"].RateApplicablePercent format_decimal(line.item.tax_percent) if line.item.tax_percent
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
xml["ram"].SpecifiedTradeSettlementLineMonetarySummation do
|
|
289
|
+
xml["ram"].LineTotalAmount format_decimal(line.line_extension_amount)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def build_item(xml, item)
|
|
296
|
+
xml["ram"].SpecifiedTradeProduct do
|
|
297
|
+
xml["ram"].SellerAssignedID item.sellers_identifier if item.sellers_identifier
|
|
298
|
+
xml["ram"].Name item.name
|
|
299
|
+
xml["ram"].Description item.description if item.description
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def format_cii_date(date)
|
|
304
|
+
date.strftime("%Y%m%d")
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def format_decimal(value)
|
|
308
|
+
return value.to_s unless value.is_a?(BigDecimal)
|
|
309
|
+
str = value.to_s("F")
|
|
310
|
+
str.sub(/\.?0+$/, "")
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|