diff --git a/banking/docs/en/banking/sepa-payment-order.md b/banking/docs/en/banking/sepa-payment-order.md index d3aac62d..efeb8871 100644 --- a/banking/docs/en/banking/sepa-payment-order.md +++ b/banking/docs/en/banking/sepa-payment-order.md @@ -24,9 +24,9 @@ SEPA Payment Orders enable users to: - Ensures compliance with IBAN standards before submission ### 2. Automatic BIC and Bank Name Resolution -- EUR payments from an EEA IBAN to an EEA IBAN do not need a BIC. The system removes the BIC from these payments. +- EUR payments from an EEA IBAN to an EEA IBAN do not need a BIC. The system leaves out the BIC from the XML file for these payments. - All other payments (e.g. from or to Switzerland) keep the BIC. If the BIC does not belong to the IBAN, submit fails and the error shows the row. -- For German IBANs (starting with "DE"), the system populates the bank name. +- The system sets the bank name from the IBAN (German, Austrian and Swiss IBANs) or else from the BIC. When neither is known, the bank name is empty. - A **Bank Account** with a German IBAN can only link to a **Bank** whose _SWIFT Number_ belongs to that IBAN. ### 3. Currency Validation diff --git a/banking/ebics/doctype/sepa_payment_order/sepa_payment_order.py b/banking/ebics/doctype/sepa_payment_order/sepa_payment_order.py index eae60044..b64c1ae6 100644 --- a/banking/ebics/doctype/sepa_payment_order/sepa_payment_order.py +++ b/banking/ebics/doctype/sepa_payment_order/sepa_payment_order.py @@ -76,18 +76,9 @@ class SEPAPaymentOrder(Document): def before_validate(self): register_fintech() - from fintech import iban - - debtor_in_eea = (self.iban or "")[:2].upper() in EEA_COUNTRIES for payment in self.payments: - country = (payment.iban or "")[:2].upper() - if debtor_in_eea and country in EEA_COUNTRIES and payment.currency == "EUR": - # SEPA payments within the EEA are IBAN-only. A BIC copied from the Bank can - # belong to another branch, which makes the payment fail. - payment.swift_number = None - if country == "DE" and not payment.bank_name: - with contextlib.suppress(Exception): - payment.bank_name = iban.get_bankname(payment.iban) + # Read-only, so always derive it from the current IBAN or BIC. Else it goes stale. + payment.bank_name = get_bank_name(payment.iban, payment.swift_number) def validate(self): self.validate_ibans() @@ -255,19 +246,27 @@ def to_sepa_credit_transfer(self) -> "SEPACreditTransfer": batch=self.batch_booking == "Process as batch", scheme=self.scheme, ) + debtor_in_eea = self.iban[:2].upper() in EEA_COUNTRIES invalid_rows = [] for payment in self.payments: iban = payment.iban.replace(" ", "") + bic = payment.swift_number + if debtor_in_eea and iban[:2].upper() in EEA_COUNTRIES and payment.currency == "EUR": + # SEPA payments within the EEA are IBAN-only. A BIC copied from the Bank can + # belong to another branch, which makes the payment fail. Keep the stored BIC, + # in case debtor, IBAN or currency change later. + bic = None + try: account = Account( - iban=(iban, payment.swift_number) if payment.swift_number else iban, + iban=(iban, bic) if bic else iban, name=payment.recipient, ) except ValueError: - if payment.swift_number: + if bic: message = _( "Row {0}: BIC {1} does not belong to IBAN {2}. Please correct or remove the BIC." - ).format(payment.idx, payment.swift_number, iban) + ).format(payment.idx, bic, iban) else: message = _("Row {0}: IBAN {1} is invalid.").format(payment.idx, iban) invalid_rows.append(message) @@ -358,6 +357,23 @@ def get_payable_expense_claims(company: str, currency: str, date: str, posted_fr ) +def get_bank_name(iban: str | None, bic: str | None) -> str | None: + """Return the bank name for a DE, AT or CH IBAN, or for a European BIC. + + NOTE: call register_fintech() before calling this function. + """ + from fintech import iban as fintech_iban + + for iban_or_bic in (iban, bic): + if not iban_or_bic: + continue + # Unsupported country or unknown BIC + with contextlib.suppress(ValueError, NotImplementedError): + return fintech_iban.get_bankname(iban_or_bic) + + return None + + @frappe.whitelist() def get_recipient_details(doctype: str, name: str): """Return recipient and IBAN for Supplier or Employee.""" diff --git a/banking/ebics/doctype/sepa_payment_order/test_sepa_payment_order.py b/banking/ebics/doctype/sepa_payment_order/test_sepa_payment_order.py index d97a3eb7..6d270e49 100644 --- a/banking/ebics/doctype/sepa_payment_order/test_sepa_payment_order.py +++ b/banking/ebics/doctype/sepa_payment_order/test_sepa_payment_order.py @@ -205,9 +205,26 @@ def test_eea_payment_has_no_bic(self): order.run_method("before_validate") - self.assertFalse(order.payments[0].swift_number) + # Stored BIC is kept, in case the payment later needs it + self.assertEqual(order.payments[0].swift_number, "DEUTDEFF") order.verify_xml_render() + def test_bank_name_follows_iban(self): + order = self.new_payment_order(execution_date=today()) + self.add_payment(order, SUPPLIER_IBAN, swift_number=None) + order.run_method("before_validate") + self.assertEqual(order.payments[0].bank_name, "BSK 1818 AG") + + # Unsupported country without BIC: stale name is cleared + order.payments[0].iban = "FR1420041010050500013M02606" + order.run_method("before_validate") + self.assertIsNone(order.payments[0].bank_name) + + # Falls back to the BIC + order.payments[0].swift_number = "BNPAFRPP" + order.run_method("before_validate") + self.assertEqual(order.payments[0].bank_name, "BNP PARIBAS SA") + def test_bic_mismatch_names_row(self): order = self.new_payment_order(execution_date=today()) self.add_payment(order, SUPPLIER_IBAN, swift_number=None)