Skip to content

Commit d4b8f42

Browse files
authored
Merge pull request #39 from prilk-consulting/fix/peppol-credit-note-and-br32-validation
fix: PEPPOL generator validation fixes and credit note sign handling
2 parents f38d7a3 + 9c588ec commit d4b8f42

File tree

1 file changed

+111
-59
lines changed

1 file changed

+111
-59
lines changed

edocument/edocument/profiles/peppol/generator.py

Lines changed: 111 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -429,15 +429,30 @@ def _add_line_item(self, root: ET.Element, item):
429429
line_id = ET.SubElement(line_elem, f"{{{self.namespaces['cbc']}}}ID")
430430
line_id.text = str(item.idx)
431431

432+
# Normalize signs for PEPPOL compliance
433+
qty = flt(item.qty, item.precision("qty"))
434+
item_amount = flt(item.amount, item.precision("amount"))
435+
item_rate = flt(item.rate, item.precision("rate"))
436+
437+
if self.document_type == "CreditNote":
438+
# Credit notes: all values positive (document type implies reversal)
439+
qty = abs(qty)
440+
item_amount = abs(item_amount)
441+
item_rate = abs(item_rate)
442+
elif item_amount < 0:
443+
# Invoice deduction line: move negative from rate/price to quantity
444+
qty = -abs(qty)
445+
item_rate = abs(item_rate)
446+
432447
quantity = ET.SubElement(line_elem, f"{{{self.namespaces['cbc']}}}{quantity_elem_name}")
433-
quantity.text = str(flt(item.qty, item.precision("qty")))
448+
quantity.text = str(qty)
434449
quantity.set("unitCode", self.map_unit_code(item.uom))
435450

436451
# Update references from invoice_line to line_elem
437452
invoice_line = line_elem
438453

439454
line_amount = ET.SubElement(invoice_line, f"{{{self.namespaces['cbc']}}}LineExtensionAmount")
440-
line_amount.text = str(flt(item.amount, item.precision("amount")))
455+
line_amount.text = f"{item_amount:.2f}"
441456
line_amount.set("currencyID", self.invoice.currency)
442457

443458
item_elem = ET.SubElement(invoice_line, f"{{{self.namespaces['cac']}}}Item")
@@ -466,7 +481,8 @@ def _add_line_item(self, root: ET.Element, item):
466481

467482
price = ET.SubElement(invoice_line, f"{{{self.namespaces['cac']}}}Price")
468483
price_amount = ET.SubElement(price, f"{{{self.namespaces['cbc']}}}PriceAmount")
469-
price_amount.text = str(flt(item.rate, item.precision("rate")))
484+
price_precision = item.precision("rate") or 2
485+
price_amount.text = f"{item_rate:.{price_precision}f}"
470486
price_amount.set("currencyID", self.invoice.currency)
471487

472488
def _add_tax_totals(self):
@@ -477,8 +493,12 @@ def _add_tax_totals(self):
477493
tax_total = ET.SubElement(self.root, f"{{{self.namespaces['cac']}}}TaxTotal")
478494

479495
# Use invoice.total_taxes_and_charges for total TaxAmount (already correctly calculated after discount)
496+
# Credit notes: use abs() since PEPPOL CreditNote expects positive values
497+
total_tax = flt(self.invoice.total_taxes_and_charges, 2)
498+
if self.document_type == "CreditNote":
499+
total_tax = abs(total_tax)
480500
tax_amount = ET.SubElement(tax_total, f"{{{self.namespaces['cbc']}}}TaxAmount")
481-
tax_amount.text = str(flt(self.invoice.total_taxes_and_charges, 2))
501+
tax_amount.text = f"{total_tax:.2f}"
482502
tax_amount.set("currencyID", self.invoice.currency)
483503

484504
# Group taxes by (category_code, rate)
@@ -500,20 +520,23 @@ def _add_tax_totals(self):
500520
if key not in tax_groups:
501521
tax_groups[key] = {"taxable_amount": 0, "tax_amount": 0}
502522

503-
item_tax_amount = flt(item.net_amount) * (rate or 0) / 100
523+
net_amount = flt(item.net_amount)
524+
if self.document_type == "CreditNote":
525+
net_amount = abs(net_amount)
526+
item_tax_amount = net_amount * (rate or 0) / 100
504527
tax_groups[key]["tax_amount"] += item_tax_amount
505-
tax_groups[key]["taxable_amount"] += flt(item.net_amount)
528+
tax_groups[key]["taxable_amount"] += net_amount
506529

507530
# Add TaxSubtotal for each (category, rate) group
508531
for (category_code, rate), data in tax_groups.items():
509532
tax_subtotal = ET.SubElement(tax_total, f"{{{self.namespaces['cac']}}}TaxSubtotal")
510533

511534
taxable_amount = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cbc']}}}TaxableAmount")
512-
taxable_amount.text = str(flt(data["taxable_amount"], 2))
535+
taxable_amount.text = f"{flt(data['taxable_amount'], 2):.2f}"
513536
taxable_amount.set("currencyID", self.invoice.currency)
514537

515538
tax_amount = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cbc']}}}TaxAmount")
516-
tax_amount.text = str(flt(data["tax_amount"], 2))
539+
tax_amount.text = f"{flt(data['tax_amount'], 2):.2f}"
517540
tax_amount.set("currencyID", self.invoice.currency)
518541

519542
tax_category = ET.SubElement(tax_subtotal, f"{{{self.namespaces['cac']}}}TaxCategory")
@@ -674,6 +697,8 @@ def _add_allowances_charges(self):
674697

675698
# Handle document-level discounts (AllowanceCharge with ChargeIndicator=false)
676699
discount_amount = flt(self.invoice.total, 2) - flt(self.invoice.net_total, 2)
700+
if self.document_type == "CreditNote":
701+
discount_amount = abs(discount_amount)
677702
if discount_amount > 0:
678703
# There's a document-level discount
679704
allowance_charge = ET.SubElement(self.root, f"{{{self.namespaces['cac']}}}AllowanceCharge")
@@ -690,52 +715,51 @@ def _add_allowances_charges(self):
690715
reason.text = reason_text
691716

692717
# MultiplierFactorNumeric: Calculate percentage (Amount / BaseAmount * 100)
693-
if self.invoice.total > 0:
694-
multiplier = (discount_amount / flt(self.invoice.total, 2)) * 100
718+
inv_total = (
719+
abs(flt(self.invoice.total, 2))
720+
if self.document_type == "CreditNote"
721+
else flt(self.invoice.total, 2)
722+
)
723+
if inv_total > 0:
724+
multiplier = (discount_amount / inv_total) * 100
695725
multiplier_factor = ET.SubElement(
696726
allowance_charge, f"{{{self.namespaces['cbc']}}}MultiplierFactorNumeric"
697727
)
698728
multiplier_factor.text = str(flt(multiplier, 6)) # 6 decimal places as in example
699729

700730
# Amount: The discount amount
701731
amount = ET.SubElement(allowance_charge, f"{{{self.namespaces['cbc']}}}Amount")
702-
amount.text = str(flt(discount_amount, 2))
732+
amount.text = f"{flt(discount_amount, 2):.2f}"
703733
amount.set("currencyID", self.invoice.currency)
704734

705735
# BaseAmount: The amount before discount (invoice.total)
706736
base_amount = ET.SubElement(allowance_charge, f"{{{self.namespaces['cbc']}}}BaseAmount")
707-
base_amount.text = str(flt(self.invoice.total, 2))
737+
base_amount.text = f"{inv_total:.2f}"
708738
base_amount.set("currencyID", self.invoice.currency)
709739

710-
# Tax Category: Use the tax rate from invoice taxes
740+
# Tax Category (BR-32: always required on AllowanceCharge)
711741
# Get the first non-Actual tax rate (usually VAT)
712-
tax_rate = None
742+
tax_rate = 0
713743
sample_tax = None
714744
for tax in self.invoice.taxes:
715745
if tax.charge_type != "Actual" and tax.rate:
716746
tax_rate = tax.rate
717747
sample_tax = tax
718748
break
719749

720-
if tax_rate and tax_rate > 0:
721-
tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory")
750+
tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory")
722751

723-
# Get category code dynamically
724-
category_code = (
725-
self.get_vat_category_code(self.invoice, tax=sample_tax) if sample_tax else "S"
726-
)
752+
category_code = self.get_vat_category_code(self.invoice, tax=sample_tax)
727753

728-
# Category ID
729-
category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID")
730-
category_id.text = category_code
754+
category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID")
755+
category_id.text = category_code
731756

732-
tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent")
733-
tax_percent.text = str(flt(tax_rate, 2))
757+
tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent")
758+
tax_percent.text = str(flt(tax_rate, 2))
734759

735-
# Tax scheme
736-
tax_scheme = ET.SubElement(tax_category, f"{{{self.namespaces['cac']}}}TaxScheme")
737-
scheme_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}ID")
738-
scheme_id.text = "VAT"
760+
tax_scheme = ET.SubElement(tax_category, f"{{{self.namespaces['cac']}}}TaxScheme")
761+
scheme_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}ID")
762+
scheme_id.text = "VAT"
739763

740764
# Handle document-level charges (AllowanceCharge with ChargeIndicator=true)
741765
for tax in self.invoice.taxes:
@@ -754,28 +778,29 @@ def _add_allowances_charges(self):
754778
reason.text = tax.description
755779

756780
# Amount
781+
charge_amt = (
782+
abs(flt(tax.tax_amount, 2))
783+
if self.document_type == "CreditNote"
784+
else flt(tax.tax_amount, 2)
785+
)
757786
amount = ET.SubElement(allowance_charge, f"{{{self.namespaces['cbc']}}}Amount")
758-
amount.text = str(flt(tax.tax_amount, 2))
787+
amount.text = f"{charge_amt:.2f}"
759788
amount.set("currencyID", self.invoice.currency)
760789

761-
# Tax Category (if applicable)
762-
if tax.rate and tax.rate > 0:
763-
tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory")
790+
# Tax Category (BR-32: always required on AllowanceCharge)
791+
tax_category = ET.SubElement(allowance_charge, f"{{{self.namespaces['cac']}}}TaxCategory")
764792

765-
# Get category code dynamically
766-
category_code = self.get_vat_category_code(self.invoice, tax=tax)
793+
category_code = self.get_vat_category_code(self.invoice, tax=tax)
767794

768-
# Category ID
769-
category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID")
770-
category_id.text = category_code
795+
category_id = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}ID")
796+
category_id.text = category_code
771797

772-
tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent")
773-
tax_percent.text = str(flt(tax.rate, 2))
798+
tax_percent = ET.SubElement(tax_category, f"{{{self.namespaces['cbc']}}}Percent")
799+
tax_percent.text = str(flt(tax.rate or 0, 2))
774800

775-
# Tax scheme
776-
tax_scheme = ET.SubElement(tax_category, f"{{{self.namespaces['cac']}}}TaxScheme")
777-
scheme_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}ID")
778-
scheme_id.text = "VAT"
801+
tax_scheme = ET.SubElement(tax_category, f"{{{self.namespaces['cac']}}}TaxScheme")
802+
scheme_id = ET.SubElement(tax_scheme, f"{{{self.namespaces['cbc']}}}ID")
803+
scheme_id.text = "VAT"
779804

780805
def _set_totals(self):
781806
# Set monetary totals using ERPNext values directly
@@ -785,36 +810,46 @@ def _set_totals(self):
785810
# Add LegalMonetaryTotal section
786811
legal_total = ET.SubElement(self.root, f"{{{self.namespaces['cac']}}}LegalMonetaryTotal")
787812

813+
# For credit notes, ERPNext stores negative totals but PEPPOL requires positive values
814+
is_credit = self.document_type == "CreditNote"
815+
inv_total = abs(flt(self.invoice.total, 2)) if is_credit else flt(self.invoice.total, 2)
816+
inv_net_total = abs(flt(self.invoice.net_total, 2)) if is_credit else flt(self.invoice.net_total, 2)
817+
inv_grand_total = (
818+
abs(flt(self.invoice.grand_total, 2)) if is_credit else flt(self.invoice.grand_total, 2)
819+
)
820+
788821
# Line Extension Amount (BT-106) - Sum of Invoice line net amount (BEFORE discount)
789822
# Use invoice.total which is the sum of all item.amount values
790823
line_total = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}LineExtensionAmount")
791-
line_total.text = str(flt(self.invoice.total, 2))
824+
line_total.text = f"{inv_total:.2f}"
792825
line_total.set("currencyID", self.invoice.currency)
793826

794827
# Tax Exclusive Amount (BT-109) - Invoice total amount without VAT
795828
# This equals LineExtensionAmount - AllowanceTotalAmount = net_total (after discount, before tax)
796829
tax_exclusive_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}TaxExclusiveAmount")
797-
tax_exclusive_amount.text = str(flt(self.invoice.net_total, 2))
830+
tax_exclusive_amount.text = f"{inv_net_total:.2f}"
798831
tax_exclusive_amount.set("currencyID", self.invoice.currency)
799832

800833
# Tax Inclusive Amount (BT-112) - Invoice total amount with VAT
801834
tax_inclusive_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}TaxInclusiveAmount")
802-
tax_inclusive_amount.text = str(flt(self.invoice.grand_total, 2))
835+
tax_inclusive_amount.text = f"{inv_grand_total:.2f}"
803836
tax_inclusive_amount.set("currencyID", self.invoice.currency)
804837

805838
# Allowance Total Amount (BT-107) - Sum of allowances on document level (discount)
806839
# Calculate as difference between total (before discount) and net_total (after discount)
807-
allowance_amount = flt(self.invoice.total, 2) - flt(self.invoice.net_total, 2)
840+
allowance_amount = inv_total - inv_net_total
808841
allowance_total = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}AllowanceTotalAmount")
809-
allowance_total.text = str(flt(allowance_amount, 2))
842+
allowance_total.text = f"{flt(allowance_amount, 2):.2f}"
810843
allowance_total.set("currencyID", self.invoice.currency)
811844

812845
# Charge Total Amount (BT-108) - Sum of charges on document level
813846
# Must come AFTER AllowanceTotalAmount per XSD schema order
814847
actual_charge_total = sum(tax.tax_amount for tax in self.invoice.taxes if tax.charge_type == "Actual")
848+
if is_credit:
849+
actual_charge_total = abs(actual_charge_total)
815850
if actual_charge_total:
816851
charge_total = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}ChargeTotalAmount")
817-
charge_total.text = str(flt(actual_charge_total, 2))
852+
charge_total.text = f"{flt(actual_charge_total, 2):.2f}"
818853
charge_total.set("currencyID", self.invoice.currency)
819854
else:
820855
# Always add ChargeTotalAmount (set to 0.00 if no charges)
@@ -825,24 +860,22 @@ def _set_totals(self):
825860
# Prepaid Amount (BT-113) - Sum of amounts already paid
826861
# This is required when PayableAmount != TaxInclusiveAmount to satisfy BR-CO-16:
827862
# PayableAmount = TaxInclusiveAmount - PrepaidAmount + RoundingAmount
828-
if self.document_type != "CreditNote":
863+
if not is_credit:
829864
prepaid_value = flt(self.invoice.grand_total, 2) - flt(self.invoice.outstanding_amount, 2)
830865
if prepaid_value > 0:
831866
prepaid_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}PrepaidAmount")
832-
prepaid_amount.text = str(flt(prepaid_value, 2))
867+
prepaid_amount.text = f"{flt(prepaid_value, 2):.2f}"
833868
prepaid_amount.set("currencyID", self.invoice.currency)
834869

835870
# Payable Amount (BT-115) - Amount due for payment
836-
# For credit notes, this should be negative (amount to be credited)
837871
payable_amount = ET.SubElement(legal_total, f"{{{self.namespaces['cbc']}}}PayableAmount")
838-
if self.document_type == "CreditNote":
839-
# For credit notes, PayableAmount should be negative (the amount to be credited)
840-
# Use grand_total (which is already negative for credit notes)
841-
payable_value = flt(self.invoice.grand_total, 2)
872+
if is_credit:
873+
# For credit notes, use positive grand_total (amount to be credited)
874+
payable_value = inv_grand_total
842875
else:
843876
# For invoices, use outstanding_amount
844877
payable_value = flt(self.invoice.outstanding_amount, 2)
845-
payable_amount.text = str(payable_value)
878+
payable_amount.text = f"{payable_value:.2f}"
846879
payable_amount.set("currencyID", self.invoice.currency)
847880

848881
def initialize_peppol_xml(self) -> ET.Element:
@@ -960,12 +993,31 @@ def get_invoice_type_code(self, invoice) -> str:
960993
if hasattr(invoice, "is_return") and invoice.is_return:
961994
invoice_type_code = "381"
962995
elif hasattr(invoice, "amended_from") and invoice.amended_from:
963-
invoice_type_code = "384"
996+
# 384 (Corrected Invoice) only allowed when both parties are German
997+
if self._both_parties_german():
998+
invoice_type_code = "384"
964999
except Exception:
9651000
pass
9661001

9671002
return invoice_type_code
9681003

1004+
def _both_parties_german(self) -> bool:
1005+
# Check if both seller and buyer are German organizations
1006+
try:
1007+
seller_code = ""
1008+
if self.seller_address and self.seller_address.country:
1009+
seller_code = (
1010+
frappe.db.get_value("Country", self.seller_address.country, "code") or ""
1011+
).upper()
1012+
buyer_code = ""
1013+
if self.buyer_address and self.buyer_address.country:
1014+
buyer_code = (
1015+
frappe.db.get_value("Country", self.buyer_address.country, "code") or ""
1016+
).upper()
1017+
return seller_code == "DE" and buyer_code == "DE"
1018+
except Exception:
1019+
return False
1020+
9691021
def map_unit_code(self, erpnext_unit: str) -> str:
9701022
# Map ERPNext unit codes to PEPPOL standard unit codes using CommonCodeRetriever
9711023
if not erpnext_unit:

0 commit comments

Comments
 (0)