Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add structured ISO-11649 creditor references for EUR bank transactions #1292

Merged
49 commits merged into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
6fb092a
Add python-stdnum Python package dependency
jayaddison Dec 17, 2023
11c083e
Draft ISO-11649 reference generation for currency=EUR bank payments
jayaddison Dec 17, 2023
cf4f13d
payment model: add check-digit and customer reference validation
jayaddison Dec 17, 2023
e1fc8fc
payments: fixup for ISO11649 customer reference check-digit calculation
jayaddison Dec 17, 2023
482cfce
lint fixup: extraneous additional space after operator
jayaddison Dec 17, 2023
adcbe3e
Merge branch 'main' into issue-878/eur-bank-transfer-iso-11649-refere…
jayaddison Dec 17, 2023
5ca0fde
Add python-stdnum to missing-import-errors ignored by mypy
jayaddison Dec 17, 2023
a35cbd6
payments: derive ISO-11649 creditor reference from existing (base36-s…
jayaddison Dec 17, 2023
5d30c7e
payments: probably-too-clever hack: use a non-safechar to delimit the…
jayaddison Dec 18, 2023
0937f3c
templates: add custom formatting logic for creditor references
jayaddison Dec 18, 2023
cb01682
templates: use creditor references throughout bank-transfer-related t…
jayaddison Dec 18, 2023
7184306
Revert "payments: probably-too-clever hack: use a non-safechar to del…
jayaddison Dec 18, 2023
ecd94a9
tests: nitpick: update test fixture data
jayaddison Dec 18, 2023
c92613b
payments: during bank transaction reference matching, trim structured…
jayaddison Dec 18, 2023
bae8172
payments: nitpick: replace assertion with an if-guarded exception raise
jayaddison Dec 18, 2023
3c1afcc
payments: nitpick: rephrase database model-persistence exception message
jayaddison Dec 18, 2023
2020988
payments: extract utility method to trim ISO-11649 creditor reference…
jayaddison Dec 18, 2023
2085434
payments: expand test coverage for ISO-11649 header filtering
jayaddison Dec 18, 2023
8c8e269
Merge branch 'main' into issue-878/eur-bank-transfer-iso-11649-refere…
jayaddison Dec 22, 2023
652fb41
cleanup: remove comment
jayaddison Dec 22, 2023
6adf76c
Enable EPC QR codes for the 'invoice' and 'transfer-waiting' templates
jayaddison Dec 23, 2023
994fd88
Fixup for conditional statement in 'payment' admin template
jayaddison Dec 23, 2023
b501deb
EPC QR codes: copy CSS styling to ticket and invoice modules
jayaddison Dec 23, 2023
60ecbc8
CSS: Add 'width: fit-content' styling to EPC QR code in transfer-wait…
jayaddison Dec 23, 2023
1cc7342
EPC QR codes: add SWIFT/BIC code because it is required for non-EEA c…
jayaddison Jan 5, 2024
063d68b
payments: add an EPC QR code explainer tooltip to the bank transfer p…
jayaddison Jan 7, 2024
e0507d1
payments: add missing currency=EUR condition for transfer-waiting tem…
jayaddison Jan 7, 2024
9d2465a
payments: separate-out third paragraph in EPC QR explainer
jayaddison Jan 7, 2024
3437acd
payments: attempt to improve formatting of EPC QR explainer tooltip
jayaddison Jan 7, 2024
c760460
cleanup: recombine the payment-details and epc-qr definition lists
jayaddison Jan 7, 2024
3a6d8cf
Merge branch 'main' into issue-878/eur-bank-transfer-iso-11649-refere…
jayaddison Jan 8, 2024
be6f229
Merge branch 'main' into issue-878/eur-bank-transfer-iso-11649-refere…
jayaddison Jan 8, 2024
8aad8ba
aria-controls: this references an element ID directly, and is not a C…
jayaddison Jan 10, 2024
529d9c1
fixup: update the EPC QR aria-expanded attribute to correctly indicat…
jayaddison Jan 10, 2024
6f41ae5
Merge branch 'main' into issue-878/eur-bank-transfer-iso-11649-refere…
jayaddison Jan 22, 2024
c6704b0
Merge branch 'main' into issue-878/eur-bank-transfer-iso-11649-refere…
jayaddison Jan 24, 2024
9419382
payments: additional payment-reference-format comment and test case
jayaddison Feb 22, 2024
98b0133
payments: adjust ISO11649 header-match regex (remove initial word-bou…
jayaddison Feb 22, 2024
25ebe92
payments: use a stricter ISO11649 match regex on the expectation that…
jayaddison Feb 22, 2024
61ad4ef
refactor: consolidate regex substitution call onto a single line
jayaddison Feb 22, 2024
2a26cd2
payments: update assertion method
jayaddison Feb 22, 2024
f58ff7d
Merge branch 'main' into issue-878/eur-bank-transfer-iso-11649-refere…
jayaddison Feb 22, 2024
ef656f4
nitpick: update/correct comment
jayaddison Feb 22, 2024
c3baec3
tests: fixup: adjust test case so that it demonstrates ISO11649 heade…
jayaddison Feb 22, 2024
9d4c4dc
Revert "payments: use a stricter ISO11649 match regex on the expectat…
jayaddison Feb 22, 2024
39379a6
Merge branch 'main' into issue-878/eur-bank-transfer-iso-11649-refere…
jayaddison Feb 25, 2024
cc897e1
Back-out changes that relate purely to QR codes, leaving only ISO-116…
jayaddison Feb 25, 2024
5847ede
BankTransaction matching: remove the nearly-complete-prefix-match fun…
jayaddison Feb 25, 2024
afd2b87
Refactor: extract a BankTransaction._recognized_bankrefs property
jayaddison Feb 25, 2024
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
3 changes: 3 additions & 0 deletions .mypy.ini
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,6 @@ ignore_missing_imports = True

[mypy-flask_mailman.*]
ignore_missing_imports = True

[mypy-stdnum.*]
ignore_missing_imports = True
2 changes: 1 addition & 1 deletion apps/base/dev/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,7 +591,7 @@ def create_bank_accounts():
institution="London Bank",
address="13 Bartlett Place, London, WC1B 4NM",
iban="GB33BUKB20201555555555",
swift="GB33BUKB",
swift="BUKBGB33",
)
for acct in [gbp, eur]:
try:
Expand Down
2 changes: 2 additions & 0 deletions apps/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ def format_price(price, currency=None, after=False):

@app_obj.template_filter("bankref")
def format_bankref(bankref):
if bankref.startswith("RF"):
return " ".join(wrap(bankref, 4))
return "%s-%s" % (bankref[:4], bankref[4:])

@app_obj.context_processor
Expand Down
3 changes: 2 additions & 1 deletion apps/common/epc.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ def make_epc_qrfile(payment: BankPayment, **kwargs) -> BytesIO:
name=payment.recommended_destination.payee_name,
iban=payment.recommended_destination.iban,
amount=payment.amount,
reference=payment.bankref,
reference=payment.customer_reference,
bic=payment.recommended_destination.swift,
encoding=1,
)
qr.save(qrfile, **kwargs)
Expand Down
65 changes: 46 additions & 19 deletions models/payment.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
import re
from decimal import Decimal
from datetime import datetime, timedelta
from typing import Optional
from typing import Iterable, Optional
from sqlalchemy import event, func, column
from sqlalchemy.orm import Session, aliased
from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound
from sqlalchemy_continuum.utils import version_class, transaction_class
from stdnum import iso11649
from stdnum.iso7064 import mod_97_10

from main import db
from . import (
Expand Down Expand Up @@ -314,6 +316,23 @@ def recommended_destination(self):
except (MultipleResultsFound, NoResultFound):
continue

@property
def customer_reference(self):
if self.id is None:
raise Exception(
"Customer references can only be generated for payments that have "
"been persisted to the database."
)

# Derive an ISO-11649 payment reference for EUR-currency payments
if self.currency == "EUR":
order_check_digits = mod_97_10.calc_check_digits(f"{self.bankref}RF")
customer_reference = f"RF{order_check_digits}{self.bankref}"
assert iso11649.is_valid(customer_reference)
return customer_reference
else:
return self.bankref


class BankAccount(BaseModel):
__tablename__ = "bank_account"
Expand Down Expand Up @@ -423,7 +442,21 @@ def get_matching(self):
return matching

def match_payment(self) -> Optional[BankPayment]:
for bankref in self._recognized_bankrefs:
try:
return BankPayment.query.filter_by(bankref=bankref).one()
except NoResultFound:
continue

return None

@property
def _recognized_bankrefs(self) -> Iterable[str]:
"""
Given a customer reference text received on a bank transfer, scan for
substrings that appear to be valid bank references that we can match
against bank transfer records in the database.

We need to deal with human error and character deletion without colliding.
Unless we use some sort of coding, the minimum length of a bankref should
be 8, although 7 is workable. For reference:
Expand All @@ -447,30 +480,24 @@ def match_payment(self) -> Optional[BankPayment]:

where serial is a 6-digit number, and ref is often the payee
name again, or REFERENCE, and always truncated to 8 chars.

We've also received ISO-11649 payment references formatted:

ref/timestamp_plus_iban

Where the timestamp contains digits and is immediately followed by
the payer's IBAN (no delimiter between the two).
"""

ref = self.payee.upper()
hdr = "(RF[0-9][0-9] ?)?" # optional ISO11649 header + check-digits

found = re.findall("[%s]{4}[- ]?[%s]{4}" % (safechars, safechars), ref)
for f in found:
found = re.findall("%s([%s]{4}[- ]?[%s]{4})" % (hdr, safechars, safechars), ref)
for iso_header, f in found:
bankref = f.replace("-", "").replace(" ", "")
try:
return BankPayment.query.filter_by(bankref=bankref).one()
except NoResultFound:
if iso_header and not iso11649.is_valid(iso_header + bankref):
continue

# It's pretty safe to match against the last character being lost
found = re.findall("[%s]{4}[- ]?[%s]{3}" % (safechars, safechars), ref)
for f in found:
bankref = f.replace("-", "").replace(" ", "")
try:
return BankPayment.query.filter(
BankPayment.bankref.startswith(bankref)
).one()
except NoResultFound:
continue

return None
yield bankref


db.Index(
Expand Down
18 changes: 17 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ pywisetransfer = "^0.3.1"
freezegun = "^1.1.0"
logging_tree = "^1.9"
flask-mailman = "^0.3.0"
python-stdnum = "^1.19"


[tool.poetry.group.dev.dependencies]
Expand Down
2 changes: 1 addition & 1 deletion templates/admin/_paymenthelpers.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<dt>Total</dt><dd>{{ payment.amount | price(payment.currency) }}</dd>
<dt>Provider</dt><dd>{{payment.provider}}</dd>
{%- if payment.provider == 'banktransfer' %}
<dt>Reference</dt><dd>{{ payment.bankref | bankref }}</dd>
<dt>Reference</dt><dd>{{ payment.customer_reference | bankref }}</dd>
{% elif payment.provider == 'stripe' %}
<dt>Charge ID</dt><dd><a href="https://dashboard.stripe.com/search?query={{payment.charge_id}}">{{payment.charge_id}}</a></dd>
<dt>Intent ID</dt><dd><a href="https://dashboard.stripe.com/search?query={{payment.intent_id}}">{{payment.intent_id}}</a></dd>
Expand Down
2 changes: 1 addition & 1 deletion templates/admin/accounts/txn-suggest-payments.html
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ <h2>Reconcile transaction</h2>
<td><b>{{ payment.user.name }}</b></td>
<td><a href="{{ url_for('.user', user_id=payment.user.id) }}">{{ payment.user.email }}</a></td>
<td><b>{{ payment.amount | price(payment.currency) }}</b></td>
<td>{{ payment.bankref | bankref }}</td>
<td>{{ payment.customer_reference | bankref }}</td>
<td>
<a class="btn btn-warning" href="{{ url_for('admin.transaction_reconcile', txn_id=txn.id, payment_id=payment.id) }}">Reconcile</a>
</td>
Expand Down
2 changes: 1 addition & 1 deletion templates/admin/payments/payment-send-reminder.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ <h2>Payment reminder email</h2>
<dt>Price</dt><dd>{{ payment.amount | price(payment.currency) }}</dd>
{%- if payment.provider == 'banktransfer' %}
<dt>Bank</dt><dd>{{ account.institution }} ({{ account.currency }})</dd>
<dt>Reference</dt><dd>{{ payment.bankref | bankref }}</dd>
<dt>Reference</dt><dd>{{ payment.customer_reference | bankref }}</dd>
{% endif -%}
<dt>Expires</dt><dd>{{ payment.expires and payment.expires.strftime('%Y-%m-%d %H:%M:%S') }}</dd>
<dt>Already bought</dt><dd>{{ payment.user.get_owned_tickets(paid=True) | list | count }}</dd>
Expand Down
2 changes: 1 addition & 1 deletion templates/emails/tickets-paid-email-banktransfer.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
{% block body %}
Hi {{ user.name }},

This is to confirm that we've received {{ payment.amount | price(payment.currency) }} for transaction {{ payment.bankref | bankref }} from
This is to confirm that we've received {{ payment.amount | price(payment.currency) }} for transaction {{ payment.customer_reference | bankref }} from
you as payment for {% if payment.purchases | count > 1 %}{{ payment.purchases | count }} tickets{% else %}a ticket{% endif %} for Electromagnetic Field.

{% if feature_enabled('ISSUE_TICKETS') -%}
Expand Down
2 changes: 1 addition & 1 deletion templates/payments/invoice.html
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ <h2>{% if mode == 'invoice' %}VAT Invoice{% else %}Receipt{% endif %}</h2>
<dt>SWIFT</dt><dd>{{ account.swift }}</dd>
<dt>IBAN</dt><dd>{{ account.iban | iban }}</dd>
{% endif %}
<dt>Reference</dt><dd>{{ payment.bankref | bankref }}</dd>
<dt>Reference</dt><dd>{{ payment.customer_reference | bankref }}</dd>
{#
{% if payment.provider == 'banktransfer' and payment.currency == 'EUR' and payment.state == 'inprogress' %}
<dt>EPC QR Code</dt><dd>{{ render_epc_qr(payment) }}</dd>
Expand Down
2 changes: 1 addition & 1 deletion templates/payments/transfer-waiting.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ <h2>Thank you!</h2>
<dt>IBAN</dt><dd>{{ account.iban | iban }}</dd>
{% endif %}
<dt>Amount</dt><dd>{{ payment.amount | price(payment.currency) }}</dd>
<dt>Reference</dt><dd>{{ payment.bankref | bankref }}</dd>
<dt>Reference</dt><dd>{{ payment.customer_reference | bankref }}</dd>
</dl>

{% if SITE_STATE != 'after-event' %}
Expand Down
34 changes: 32 additions & 2 deletions tests/test_epc.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,43 @@
import pytest

from pyzbar.pyzbar import decode
import re
from stdnum import iso11649

from main import db
from apps.common.epc import format_inline_epc_qr
from models.payment import BankPayment
from models.user import User

from tests._utils import render_svg


@pytest.mark.parametrize(
"customer_reference, expected_format",
[
("VFH9K3RQ", "VFH9-K3RQ"),
("RF679HKFVR8Q", "RF67 9HKF VR8Q"),
],
)
def test_customer_reference_display_format(app, customer_reference, expected_format):
assert app.jinja_env.filters["bankref"](customer_reference) == expected_format


def test_format_inline_epc_qr(app):
user = User("[email protected]", "test_invoice_user")
db.session.add(user)
db.session.commit()

payment = BankPayment(currency="EUR", amount=10)
payment.user_id = user.id

# Persist the payment object so that a sequence ID is generated for it
db.session.add(payment)
db.session.commit()

# Ensure that the structured creditor reference is valid and contains the bankref
assert iso11649.is_valid(payment.customer_reference)
assert re.match(f"^RF[0-9][0-9]{payment.bankref}$", payment.customer_reference)

qr_inline = format_inline_epc_qr(payment)
qr_image = render_svg(qr_inline)
Expand All @@ -17,12 +47,12 @@ def test_format_inline_epc_qr(app):
"002",
"1",
"SCT",
"",
"BUKBGB33",
"EMF Festivals Ltd",
"GB33BUKB20201555555555",
"EUR10",
"",
payment.bankref,
payment.customer_reference,
]

decoded = decode(qr_image)
Expand Down
31 changes: 31 additions & 0 deletions tests/test_payment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import pytest

from models.payment import BankTransaction


@pytest.mark.parametrize(
"payee, bankref",
[
("M87X CJ3Q", "M87XCJ3Q"),
("m87x Cj3q", "M87XCJ3Q"),
("prefix*M87X CJ3Q*suffix", "M87XCJ3Q"),
("RF91 M87X CJ3Q", "M87XCJ3Q"),
("RF91M87XCJ3Q", "M87XCJ3Q"),
("name value RF52RF23MHBY type value", "RF23MHBY"),
("prefix*RF52RF23MHBY*suffix", "RF23MHBY"),
("RF52RF23MHBY/20250102090807GB33BUKB20201555555555", "RF23MHBY"),
("RF52RF23MHBY", "RF23MHBY"),
("RF33*RF52RF23MHBY*RF33", "RF23MHBY"),
("RF23MHBY", "RF23MHBY"),
("RF33*RF23MHBY*RF33", "RF23MHBY"),
],
)
def test_bankref_recognition(payee, bankref):
transaction = BankTransaction(
account_id=None,
amount=0,
payee=payee,
posted=None,
type=None,
)
assert bankref in transaction._recognized_bankrefs
Loading