From 96e84dc2e7bec2579e9844d18d41d1a97c9a5835 Mon Sep 17 00:00:00 2001 From: ahmedm10 Date: Thu, 6 Nov 2025 14:18:12 +0200 Subject: [PATCH 1/2] delete quantity and use basis_quantity and billed_quantity --- lib/secretariat/invoice.rb | 6 ++--- lib/secretariat/line_item.rb | 48 ++++++++++++++++++++++++++++++------ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/lib/secretariat/invoice.rb b/lib/secretariat/invoice.rb index 4e88664..97bae19 100644 --- a/lib/secretariat/invoice.rb +++ b/lib/secretariat/invoice.rb @@ -82,11 +82,11 @@ def taxes line_items.each do |line_item| if line_item.tax_percent.nil? taxes['0'] = Tax.new(tax_percent: BigDecimal(0), tax_category: line_item.tax_category, tax_amount: BigDecimal(0)) if taxes['0'].nil? - taxes['0'].base_amount += BigDecimal(line_item.net_amount) * line_item.quantity + taxes['0'].base_amount += BigDecimal(line_item.net_amount) * line_item.billed_quantity else taxes[line_item.tax_percent] = Tax.new(tax_percent: BigDecimal(line_item.tax_percent), tax_category: line_item.tax_category) if taxes[line_item.tax_percent].nil? taxes[line_item.tax_percent].tax_amount += BigDecimal(line_item.tax_amount) - taxes[line_item.tax_percent].base_amount += BigDecimal(line_item.net_amount) * line_item.quantity + taxes[line_item.tax_percent].base_amount += BigDecimal(line_item.net_amount) * line_item.billed_quantity end end @@ -140,7 +140,7 @@ def valid? return false end line_item_sum = line_items.inject(BigDecimal(0)) do |m, item| - m + BigDecimal(item.quantity.negative? ? -item.charge_amount : item.charge_amount) + m + BigDecimal(item.billed_quantity.negative? ? -item.charge_amount : item.charge_amount) end if line_item_sum != basis @errors << "Line items do not add up to basis amount #{line_item_sum} / #{basis}" diff --git a/lib/secretariat/line_item.rb b/lib/secretariat/line_item.rb index 3418e87..1f682d0 100644 --- a/lib/secretariat/line_item.rb +++ b/lib/secretariat/line_item.rb @@ -20,7 +20,7 @@ module Secretariat LineItem = Struct.new('LineItem', :name, - :quantity, + :billed_quantity, :unit, :gross_amount, :net_amount, @@ -32,11 +32,36 @@ module Secretariat :charge_amount, :origin_country_code, :currency_code, + :basis_quantity, keyword_init: true ) do include Versioner + def initialize(**kwargs) + if kwargs.key?(:quantity) && !kwargs.key?(:billed_quantity) + kwargs[:billed_quantity] = kwargs.delete(:quantity) + end + super(**kwargs) + end + + def quantity + warn_once_quantity + billed_quantity + end + + def quantity=(val) + warn_once_quantity + self.billed_quantity = val + end + + def warn_once_quantity + unless @__warned_quantity + Kernel.warn("[secretariat] LineItem#quantity is deprecated; use #billed_quantity") + @__warned_quantity = true + end + end + def errors @errors end @@ -47,7 +72,7 @@ def valid? gross_price = BigDecimal(gross_amount) charge_price = BigDecimal(charge_amount) tax = BigDecimal(tax_amount) - unit_price = net_price * BigDecimal(quantity.abs) + unit_price = net_price * BigDecimal(billed_quantity.abs) if charge_price != unit_price @errors << "charge price and gross price times quantity deviate: #{charge_price} / #{unit_price}" @@ -65,7 +90,7 @@ def valid? self.tax_percent ||= BigDecimal(0) calculated_tax = charge_price * BigDecimal(tax_percent) / BigDecimal(100) calculated_tax = calculated_tax.round(2) - calculated_tax = -calculated_tax if quantity.negative? + calculated_tax = -calculated_tax if billed_quantity.negative? if calculated_tax != tax @errors << "Tax and calculated tax deviate: #{tax} / #{calculated_tax}" return false @@ -78,6 +103,13 @@ def unit_code UNIT_CODES[unit] || 'C62' end + # If not provided, BasisQuantity should be 1.0 (price per 1 unit) + def effective_basis_quantity + q = basis_quantity.nil? ? BigDecimal("1.0") : BigDecimal(basis_quantity.to_s) + raise ArgumentError, "basis_quantity must be > 0" if q <= 0 + q + end + def tax_category_code(version: 2) if version == 1 return TAX_CATEGORY_CODES_1[tax_category] || 'S' @@ -103,7 +135,7 @@ def to_xml(xml, line_item_index, version: 2, validate: true) if net_price&.negative? # Zugferd doesn't allow negative amounts at the item level. # Instead, a negative quantity is used. - self.quantity = -quantity + self.billed_quantity = -billed_quantity self.gross_amount = gross_price&.abs self.net_amount = net_price&.abs self.charge_amount = charge_price&.abs @@ -132,7 +164,7 @@ def to_xml(xml, line_item_index, version: 2, validate: true) 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)) + xml.text(Helpers.format(effective_basis_quantity, digits: 4)) end xml['ram'].AppliedTradeAllowanceCharge do xml['ram'].ChargeIndicator do @@ -156,7 +188,7 @@ def to_xml(xml, line_item_index, version: 2, validate: true) 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)) + xml.text(Helpers.format(effective_basis_quantity, digits: 4)) end end end @@ -166,7 +198,7 @@ def to_xml(xml, line_item_index, version: 2, validate: true) xml['ram'].send(delivery) do xml['ram'].BilledQuantity(unitCode: unit_code) do - xml.text(Helpers.format(quantity, digits: 4)) + xml.text(Helpers.format(billed_quantity, digits: 4)) end end @@ -183,7 +215,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', (billed_quantity.negative? ? -charge_amount : charge_amount), currency_code, add_currency: version == 1) end end From f98c0b25907351fc8f9dfc08f014f78674f6ef6d Mon Sep 17 00:00:00 2001 From: Amin Aldirani Date: Wed, 15 Jul 2026 15:20:32 +0200 Subject: [PATCH 2/2] MP-51710 Add seller/buyer contact to TradeParty --- lib/secretariat/trade_party.rb | 23 +++++++++++++++++++++++ test/invoice_test.rb | 15 ++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/secretariat/trade_party.rb b/lib/secretariat/trade_party.rb index ebd1afb..c596a47 100644 --- a/lib/secretariat/trade_party.rb +++ b/lib/secretariat/trade_party.rb @@ -19,8 +19,13 @@ module Secretariat TradeParty = Struct.new('TradeParty', :name, :street1, :street2, :city, :postal_code, :country_id, :vat_id, :global_id, :global_id_scheme_id, :tax_id, + :contact_name, :contact_phone, :contact_email, keyword_init: true, ) do + def contact? + contact_name.present? || contact_phone.present? || contact_email.present? + end + def to_xml(xml, exclude_tax: false, version: 2) if global_id.present? && global_id_scheme_id.present? xml['ram'].GlobalID(schemeID: global_id_scheme_id) do @@ -28,6 +33,24 @@ def to_xml(xml, exclude_tax: false, version: 2) end end xml['ram'].Name name + if version == 2 && contact? + # EN16931 BG-6 SELLER CONTACT (required by XRechnung CIUS rule BR-DE-2) + xml['ram'].DefinedTradeContact do + if contact_name.present? + xml['ram'].PersonName contact_name + end + if contact_phone.present? + xml['ram'].TelephoneUniversalCommunication do + xml['ram'].CompleteNumber contact_phone + end + end + if contact_email.present? + 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 diff --git a/test/invoice_test.rb b/test/invoice_test.rb index cf7fbb7..5cb6b11 100644 --- a/test/invoice_test.rb +++ b/test/invoice_test.rb @@ -170,7 +170,10 @@ def make_de_invoice city: 'Hamburg', postal_code: '20253', country_id: 'DE', - vat_id: 'DE304755032' + vat_id: 'DE304755032', + contact_name: 'Depfu Buchhaltung', + contact_phone: '+49 40 123456', + contact_email: 'billing@depfu.com' ) buyer = TradeParty.new( name: 'Depfu inc', @@ -454,6 +457,16 @@ def test_simple_eu_invoice_with_attachment_against_schematron assert_equal [], errors end + def test_seller_contact_is_rendered_for_v2 + xml = make_de_invoice.to_xml(version: 2) + doc = Nokogiri::XML(xml) + contact = doc.at_xpath('//ram:SellerTradeParty/ram:DefinedTradeContact', 'ram' => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100') + assert contact, 'expected DefinedTradeContact to be rendered' + assert_equal 'Depfu Buchhaltung', contact.at_xpath('ram:PersonName', 'ram' => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100').text + assert_equal '+49 40 123456', contact.at_xpath('ram:TelephoneUniversalCommunication/ram:CompleteNumber', 'ram' => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100').text + assert_equal 'billing@depfu.com', contact.at_xpath('ram:EmailURIUniversalCommunication/ram:URIID', 'ram' => 'urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100').text + end + def test_simple_de_invoice_v2 xml = make_de_invoice.to_xml(version: 2) v = Validator.new(xml, version: 2)