Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions banking/docs/en/banking/sepa-payment-order.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 30 additions & 14 deletions banking/ebics/doctype/sepa_payment_order/sepa_payment_order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading