diff --git a/lib/secretariat/constants.rb b/lib/secretariat/constants.rb index 30f2ae7..88d7168 100644 --- a/lib/secretariat/constants.rb +++ b/lib/secretariat/constants.rb @@ -23,6 +23,8 @@ module Secretariat "image/png", "text/csv" ] + + BASIS_QUANTITY = 1.0 TAX_CATEGORY_CODES = { :STANDARDRATE => "S", @@ -53,6 +55,8 @@ module Secretariat :DEBITADVICE => "31", :CREDITCARD => "48", :DEBIT => "49", + :SEPA_CREDIT => "58", + :SEPA_DEBIT => "59", :COMPENSATION => "97" } @@ -69,12 +73,14 @@ module Secretariat TAX_CALCULATION_METHODS = %i[HORIZONTAL VERTICAL NONE UNKNOWN].freeze UNIT_CODES = { - :PIECE => "C62", + :ONE => "C62", + :PIECE => "H87", :DAY => "DAY", :HECTARE => "HAR", :HOUR => "HUR", + :MONTH => "MON", :KILOGRAM => "KGM", - :KILOMETER => "KTM", + :KILOMETER => "KMT", :KILOWATTHOUR => "KWH", :FIXEDRATE => "LS", :LITRE => "LTR", @@ -89,6 +95,9 @@ module Secretariat :PERCENT => "P1", :SET => "SET", :TON => "TNE", - :WEEK => "WEE" + :WEEK => "WEE", + :BOTTLE => "BO", + :CARTON => "CT", + :CAN => "CA", } end diff --git a/lib/secretariat/invoice.rb b/lib/secretariat/invoice.rb index 4e88664..bddfc17 100644 --- a/lib/secretariat/invoice.rb +++ b/lib/secretariat/invoice.rb @@ -18,12 +18,16 @@ module Secretariat using ObjectExtensions - + Invoice = Struct.new("Invoice", :id, :issue_date, :service_period_start, :service_period_end, + :delivery_date, + :invoice_type, + :invoice_reference_number, + :invoice_reference_date, :seller, :buyer, :buyer_reference, @@ -168,10 +172,16 @@ def namespaces(version: 1) ) end - def to_xml(version: 1, validate: true) - if version < 1 || version > 2 + def to_xml(version: 1, validate: true, mode: :zugferd) + if version < 1 || version > 3 raise 'Unsupported Document Version' end + if mode != :zugferd && mode != :xrechnung + raise 'Unsupported Document Mode' + end + if mode == :xrechnung && version < 2 + raise 'Mode XRechnung requires Document Version > 1' + end if validate && !valid? raise ValidationError.new("Invoice is invalid", errors) @@ -186,8 +196,17 @@ def to_xml(version: 1, validate: true) context = by_version(version, 'SpecifiedExchangedDocumentContext', 'ExchangedDocumentContext') xml['rsm'].send(context) do + if version == 3 && mode == :xrechnung + xml['ram'].BusinessProcessSpecifiedDocumentContextParameter do + xml['ram'].ID 'urn:fdc:peppol.eu:2017:poacc:billing:01:1.0' + end + end xml['ram'].GuidelineSpecifiedDocumentContextParameter do version_id = by_version(version, 'urn:ferd:CrossIndustryDocument:invoice:1p0:comfort', 'urn:cen.eu:en16931:2017') + if mode == :xrechnung + version_id += '#compliant#urn:xoev-de:kosit:standard:xrechnung_2.3' if version == 2 + version_id += '#compliant#urn:xeinkauf.de:kosit:xrechnung_3.0' if version == 3 + end xml['ram'].ID version_id end end @@ -199,7 +218,7 @@ def to_xml(version: 1, validate: true) if version == 1 xml['ram'].Name "RECHNUNG" end - xml['ram'].TypeCode '380' # TODO: make configurable + xml['ram'].TypeCode invoice_type || '380' xml['ram'].IssueDateTime do xml['udt'].DateTimeString(format: '102') do xml.text(issue_date.strftime("%Y%m%d")) @@ -215,7 +234,7 @@ def to_xml(version: 1, validate: true) transaction = by_version(version, 'SpecifiedSupplyChainTradeTransaction', 'SupplyChainTradeTransaction') xml['rsm'].send(transaction) do - if version == 2 + if version >= 2 line_items.each_with_index do |item, i| item.to_xml(xml, i + 1, version: version, validate: validate) # one indexed end @@ -233,7 +252,7 @@ def to_xml(version: 1, validate: true) xml['ram'].BuyerTradeParty do buyer.to_xml(xml, version: version) end - if version == 2 + if version >= 2 if Array(attachments).size > 0 attachments.each_with_index do |attachment, index| attachment.to_xml(xml, index, version: version, validate: validate) @@ -245,7 +264,7 @@ def to_xml(version: 1, validate: true) delivery = by_version(version, 'ApplicableSupplyChainTradeDelivery', 'ApplicableHeaderTradeDelivery') xml['ram'].send(delivery) do - if version == 2 + if version >= 2 xml['ram'].ShipToTradeParty do buyer.to_xml(xml, exclude_tax: true, version: version) end @@ -253,7 +272,11 @@ def to_xml(version: 1, validate: true) xml['ram'].ActualDeliverySupplyChainEvent do xml['ram'].OccurrenceDateTime do xml['udt'].DateTimeString(format: '102') do - xml.text(issue_date.strftime("%Y%m%d")) + if delivery_date.nil? || issue_date == delivery_date + xml.text(issue_date.strftime("%Y%m%d")) + else + xml.text(delivery_date.strftime("%Y%m%d")) + end end end end @@ -326,6 +349,18 @@ def to_xml(version: 1, validate: true) Helpers.currency_element(xml, 'ram', 'TotalPrepaidAmount', paid_amount, currency_code, add_currency: version == 1) Helpers.currency_element(xml, 'ram', 'DuePayableAmount', due_amount, currency_code, add_currency: version == 1) end + + if invoice_reference_number && invoice_reference_date + invoice_reference = by_version(version, 'InvoiceReferencedDocument', 'InvoiceReferencedDocument') + xml['ram'].send(invoice_reference) do + xml['ram'].IssuerAssignedID invoice_reference_number + xml['ram'].FormattedIssueDateTime do + xml['qdt'].DateTimeString(format: '102') do + xml.text(invoice_reference_date.strftime('%Y%m%d')) + end + end + end + end end if version == 1 line_items.each_with_index do |item, i| diff --git a/lib/secretariat/line_item.rb b/lib/secretariat/line_item.rb index 3418e87..e006db2 100644 --- a/lib/secretariat/line_item.rb +++ b/lib/secretariat/line_item.rb @@ -48,6 +48,7 @@ def valid? charge_price = BigDecimal(charge_amount) tax = BigDecimal(tax_amount) unit_price = net_price * BigDecimal(quantity.abs) + unit_price = unit_price.round(2) if charge_price != unit_price @errors << "charge price and gross price times quantity deviate: #{charge_price} / #{unit_price}" @@ -55,7 +56,7 @@ def valid? end if discount_amount discount = BigDecimal(discount_amount) - calculated_net_price = (gross_price - discount).round(2, :down) + calculated_net_price = (gross_price - discount).round(2) if calculated_net_price != net_price @errors = "Calculated net price and net price deviate: #{calculated_net_price} / #{net_price}" return false @@ -99,8 +100,8 @@ def to_xml(xml, line_item_index, version: 2, validate: true) if net_price&.zero? self.tax_percent = 0 end - - if net_price&.negative? + + if net_price&.negative? || gross_price&.negative? # Zugferd doesn't allow negative amounts at the item level. # Instead, a negative quantity is used. self.quantity = -quantity @@ -117,7 +118,7 @@ def to_xml(xml, line_item_index, version: 2, validate: true) xml['ram'].AssociatedDocumentLineDocument do xml['ram'].LineID line_item_index end - if (version == 2) + if (version >= 2) xml['ram'].SpecifiedTradeProduct do xml['ram'].Name name xml['ram'].OriginTradeCountry do @@ -128,35 +129,59 @@ def to_xml(xml, line_item_index, version: 2, validate: true) agreement = by_version(version, 'SpecifiedSupplyChainTradeAgreement', 'SpecifiedLineTradeAgreement') xml['ram'].send(agreement) do - xml['ram'].GrossPriceProductTradePrice do - Helpers.currency_element(xml, 'ram', 'ChargeAmount', gross_amount, currency_code, add_currency: version == 1, digits: 4) - if version == 2 && discount_amount - xml['ram'].BasisQuantity(unitCode: unit_code) do - xml.text(Helpers.format(quantity, digits: 4)) - end - xml['ram'].AppliedTradeAllowanceCharge do - xml['ram'].ChargeIndicator do - xml['udt'].Indicator 'false' + if gross_amount + xml['ram'].GrossPriceProductTradePrice do + Helpers.currency_element(xml, 'ram', 'ChargeAmount', gross_amount, currency_code, add_currency: version == 1, digits: 4) + if version >= 2 + xml['ram'].BasisQuantity(unitCode: unit_code) do + xml.text(Helpers.format(BASIS_QUANTITY, digits: 4)) + end + if discount_amount + xml['ram'].AppliedTradeAllowanceCharge do + xml['ram'].ChargeIndicator do + xml['udt'].Indicator 'false' + end + Helpers.currency_element(xml, 'ram', 'ActualAmount', discount_amount, currency_code, add_currency: version == 1) + xml['ram'].Reason discount_reason + end end - Helpers.currency_element(xml, 'ram', 'ActualAmount', discount_amount, currency_code, add_currency: version == 1) - xml['ram'].Reason discount_reason end - end - if version == 1 && discount_amount - xml['ram'].AppliedTradeAllowanceCharge do - xml['ram'].ChargeIndicator do - xml['udt'].Indicator 'false' + if version == 1 && discount_amount + xml['ram'].AppliedTradeAllowanceCharge do + xml['ram'].ChargeIndicator do + xml['udt'].Indicator 'false' + end + Helpers.currency_element(xml, 'ram', 'ActualAmount', discount_amount, currency_code, add_currency: version == 1) + xml['ram'].Reason discount_reason end - Helpers.currency_element(xml, 'ram', 'ActualAmount', discount_amount, currency_code, add_currency: version == 1) - xml['ram'].Reason discount_reason end end end - xml['ram'].NetPriceProductTradePrice do - Helpers.currency_element(xml, 'ram', 'ChargeAmount', net_amount, currency_code, add_currency: version == 1, digits: 4) - if version == 2 - xml['ram'].BasisQuantity(unitCode: unit_code) do - xml.text(Helpers.format(quantity, digits: 4)) + if net_amount + xml['ram'].NetPriceProductTradePrice do + Helpers.currency_element(xml, 'ram', 'ChargeAmount', net_amount, currency_code, add_currency: version == 1, digits: 4) + if version >= 2 + xml['ram'].BasisQuantity(unitCode: unit_code) do + xml.text(Helpers.format(BASIS_QUANTITY, digits: 4)) + end + if discount_amount + xml['ram'].AppliedTradeAllowanceCharge do + xml['ram'].ChargeIndicator do + xml['udt'].Indicator 'false' + end + Helpers.currency_element(xml, 'ram', 'ActualAmount', discount_amount, currency_code, add_currency: version == 1) + xml['ram'].Reason discount_reason + end + end + if version == 1 && discount_amount + xml['ram'].AppliedTradeAllowanceCharge do + xml['ram'].ChargeIndicator do + xml['udt'].Indicator 'false' + end + Helpers.currency_element(xml, 'ram', 'ActualAmount', discount_amount, currency_code, add_currency: version == 1) + xml['ram'].Reason discount_reason + end + end end end end @@ -183,7 +208,7 @@ def to_xml(xml, line_item_index, version: 2, validate: true) end monetary_summation = by_version(version, 'SpecifiedTradeSettlementMonetarySummation', 'SpecifiedTradeSettlementLineMonetarySummation') xml['ram'].send(monetary_summation) do - Helpers.currency_element(xml, 'ram', 'LineTotalAmount', (quantity.negative? ? -charge_amount : charge_amount), currency_code, add_currency: version == 1) + Helpers.currency_element(xml, 'ram', 'LineTotalAmount', (quantity.negative? ? -charge_amount : charge_amount), currency_code, add_currency: version == 1) end end diff --git a/lib/secretariat/trade_party.rb b/lib/secretariat/trade_party.rb index ebd1afb..c15afaf 100644 --- a/lib/secretariat/trade_party.rb +++ b/lib/secretariat/trade_party.rb @@ -18,7 +18,7 @@ module Secretariat using ObjectExtensions TradeParty = Struct.new('TradeParty', - :name, :street1, :street2, :city, :postal_code, :country_id, :vat_id, :global_id, :global_id_scheme_id, :tax_id, + :name, :street1, :street2, :city, :postal_code, :country_id, :vat_id, :contact_name, :contact_phone, :contact_email, :global_id, :global_id_scheme_id, :tax_id, keyword_init: true, ) do def to_xml(xml, exclude_tax: false, version: 2) @@ -28,6 +28,21 @@ def to_xml(xml, exclude_tax: false, version: 2) end end xml['ram'].Name name + if contact_name && contact_name != '' + xml['ram'].DefinedTradeContact do + xml['ram'].PersonName contact_name + if contact_phone && contact_phone != '' + xml['ram'].TelephoneUniversalCommunication do + xml['ram'].CompleteNumber contact_phone + end + end + if contact_email && contact_email != '' + xml['ram'].EmailURIUniversalCommunication do + xml['ram'].URIID contact_email + end + end + end + end xml['ram'].PostalTradeAddress do xml['ram'].PostcodeCode postal_code xml['ram'].LineOne street1 @@ -37,6 +52,13 @@ def to_xml(xml, exclude_tax: false, version: 2) xml['ram'].CityName city xml['ram'].CountryID country_id end + if version == 3 && contact_email.present? + xml['ram'].URIUniversalCommunication do + xml['ram'].URIID(schemeID: 'EM') do + xml.text(contact_email) + end + end + end if !exclude_tax && vat_id.present? xml['ram'].SpecifiedTaxRegistration do xml['ram'].ID(schemeID: 'VA') do diff --git a/lib/secretariat/versioner.rb b/lib/secretariat/versioner.rb index 6e60ccf..529afa0 100644 --- a/lib/secretariat/versioner.rb +++ b/lib/secretariat/versioner.rb @@ -15,11 +15,11 @@ =end module Secretariat module Versioner - def by_version(version, v1, v2) + def by_version(version, v1, v2_or_v3) if version == 1 v1 - elsif version == 2 - v2 + elsif version == 2 || version == 3 + v2_or_v3 else raise "Unsupported Version: #{version}" end diff --git a/test/invoice_test.rb b/test/invoice_test.rb index cf7fbb7..0ee9236 100644 --- a/test/invoice_test.rb +++ b/test/invoice_test.rb @@ -40,6 +40,7 @@ def make_eu_invoice(tax_category: :REVERSECHARGE) issue_date: Date.today, service_period_start: Date.today, service_period_end: Date.today + 30, + delivery_date: Date.today + 30, seller: seller, buyer: buyer, line_items: [line_item], @@ -90,6 +91,7 @@ def make_foreign_invoice(tax_category: :TAXEXEMPT) issue_date: Date.today, service_period_start: Date.today, service_period_end: Date.today + 30, + delivery_date: Date.today + 30, seller: seller, buyer: buyer, line_items: [line_item], @@ -102,7 +104,8 @@ def make_foreign_invoice(tax_category: :TAXEXEMPT) grand_total_amount: BigDecimal('29'), due_amount: 0, paid_amount: 29, - payment_due_date: Date.today + 14 + payment_due_date: Date.today + 14, + payment_terms_text: 'paid' ) end @@ -146,6 +149,7 @@ def make_eu_invoice_with_attachment issue_date: Date.today, service_period_start: Date.today, service_period_end: Date.today + 30, + delivery_date: Date.today + 30, seller: seller, buyer: buyer, line_items: [line_item], @@ -200,6 +204,7 @@ def make_de_invoice issue_date: Date.today, service_period_start: Date.today, service_period_end: Date.today + 30, + delivery_date: Date.today + 30, seller: seller, buyer: buyer, buyer_reference: "112233", @@ -281,6 +286,7 @@ def make_de_invoice_with_multiple_tax_rates issue_date: Date.today, service_period_start: Date.today, service_period_end: Date.today + 30, + delivery_date: Date.today + 30, seller: seller, buyer: buyer, buyer_reference: "112233", @@ -335,6 +341,7 @@ def make_negative_de_invoice issue_date: Date.today, service_period_start: Date.today, service_period_end: Date.today + 30, + delivery_date: nil, seller: seller, buyer: buyer, buyer_reference: "112233",