Skip to content

Commit b84e54f

Browse files
committed
feat: track VAT validation in the database
This makes the handling more robust and better deals with retries
1 parent 084c113 commit b84e54f

File tree

4 files changed

+82
-19
lines changed

4 files changed

+82
-19
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 5.2.8 on 2025-12-12 07:59
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("payments", "0054_remove_payment_repeat_value"),
9+
]
10+
11+
operations = [
12+
migrations.AddField(
13+
model_name="customer",
14+
name="vat_validated",
15+
field=models.DateTimeField(blank=True, db_index=True, null=True),
16+
),
17+
]

weblate_web/payments/models.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
from weblate_web.utils import get_site_url
4747

4848
from .utils import send_notification, validate_email
49-
from .validators import validate_vatin
49+
from .validators import VAT_VALIDITY_DAYS, validate_vatin, validate_vatin_offline
5050

5151
if TYPE_CHECKING:
5252
from weblate_web.invoices.models import Invoice
@@ -110,6 +110,7 @@ class Customer(models.Model):
110110
"Please fill in European Union VAT ID, leave blank if not applicable."
111111
),
112112
)
113+
vat_validated = models.DateTimeField(blank=True, null=True, db_index=True)
113114
tax = models.CharField(
114115
max_length=200,
115116
blank=True,
@@ -188,6 +189,17 @@ def __str__(self) -> str:
188189
return f"{self.verbose_name} ({self.email})"
189190
return self.verbose_name
190191

192+
def save(self, **kwargs) -> None:
193+
if self.vat:
194+
try:
195+
# This should be cached due to UI validation
196+
validate_vatin(self.vat)
197+
except ValidationError:
198+
self.vat_validated = None
199+
else:
200+
self.vat_validated = timezone.now()
201+
super().save(**kwargs)
202+
191203
def get_absolute_url(self) -> str:
192204
return reverse("crm:customer-detail", kwargs={"pk": self.pk})
193205

@@ -324,17 +336,34 @@ def merge(self, other: Customer, *, user: User | None = None) -> None:
324336
)
325337
other.delete()
326338

327-
def validate_vatin(self):
339+
def validate_vatin(self, *, automated: bool = False):
328340
from weblate_web.crm.models import Interaction # noqa: PLC0415
329341

342+
now = timezone.now()
343+
344+
# Skip payment originated validation if we have validated recently
345+
if (
346+
not automated
347+
and self.vat_validated is not None
348+
and self.vat_validated > now - timedelta(days=VAT_VALIDITY_DAYS)
349+
):
350+
# Perform offline validation only
351+
validate_vatin_offline(self.vat)
352+
return
353+
330354
try:
331355
validate_vatin(self.vat)
332356
except ValidationError as error:
333-
self.interaction_set.create(
334-
origin=Interaction.Origin.VIES,
335-
summary=error.code or str(error.message),
336-
)
357+
# Log interactive validation error
358+
if automated:
359+
self.interaction_set.create(
360+
origin=Interaction.Origin.VIES,
361+
summary=error.code or str(error.message),
362+
)
337363
raise
364+
else:
365+
self.vat_validated = timezone.now()
366+
self.save(update_fields=["vat_validated"])
338367

339368

340369
RECURRENCE_CHOICES = [

weblate_web/payments/validators.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import NotRequired, TypedDict
1+
from contextlib import suppress
2+
from typing import TYPE_CHECKING, NotRequired, TypedDict
23

34
import sentry_sdk
45
from django.core.cache import cache
@@ -8,6 +9,11 @@
89
from vies.types import VATIN
910
from zeep.exceptions import Error
1011

12+
if TYPE_CHECKING:
13+
from django_stubs_ext import StrOrPromise
14+
15+
VAT_VALIDITY_DAYS = 7
16+
1117

1218
class VatinValidation(TypedDict):
1319
valid: bool
@@ -40,18 +46,18 @@ def cache_vies_data(
4046
}
4147
sentry_sdk.capture_exception()
4248
else:
43-
data = {
44-
"valid": vies_data.valid,
45-
"fault_code": vies_data.get("fault_code", ""),
46-
"fault_message": vies_data.get("fault_message", ""),
47-
}
48-
cache.set(key, data, 3600 * 24 * 7)
49+
data = {"valid": vies_data.valid}
50+
with suppress(AttributeError):
51+
data["fault_code"] = vies_data.fault_code
52+
with suppress(AttributeError):
53+
data["fault_message"] = vies_data.fault_message
54+
cache.set(key, data, 3600 * 24 * VAT_VALIDITY_DAYS)
4955

5056
return result, data
5157

5258

53-
def validate_vatin(value: str | VATIN) -> None:
54-
vatin, vies_data = cache_vies_data(value)
59+
def validate_vatin_offline(value: str | VATIN) -> None:
60+
vatin = value if isinstance(value, VATIN) else VATIN.from_str(value)
5561
try:
5662
vatin.verify_country_code()
5763
except ValidationError as error:
@@ -65,12 +71,18 @@ def validate_vatin(value: str | VATIN) -> None:
6571
msg = _("{} does not match the country's VAT ID specifications.")
6672
raise ValidationError(msg.format(vatin), code="Invalid VAT syntax") from error
6773

74+
75+
def validate_vatin(value: str | VATIN) -> None:
76+
vatin, vies_data = cache_vies_data(value)
77+
validate_vatin_offline(vatin)
78+
6879
if not vies_data["valid"]:
6980
retry_errors = {"MS_UNAVAILABLE", "MS_MAX_CONCURRENT_REQ", "TIMEOUT"}
7081
retry_codes = {"soap:Server", "other:Error", "env:Server"}
7182
code = "{}: {}".format(
7283
vies_data.get("fault_code"), vies_data.get("fault_message")
7384
)
85+
msg: StrOrPromise
7486
if (
7587
vies_data.get("fault_message") in retry_errors
7688
or vies_data.get("fault_code") in retry_codes

weblate_web/remote.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from __future__ import annotations
2222

2323
import operator
24+
from datetime import timedelta
2425
from time import sleep
2526
from typing import TYPE_CHECKING, Literal, TypedDict
2627

@@ -29,12 +30,11 @@
2930
from dateutil.parser import parse
3031
from django.conf import settings
3132
from django.core.cache import cache
32-
from django.db.models import F
33+
from django.db.models import F, Q
3334
from django.utils import timezone
3435
from wlc import Weblate, WeblateException
3536

3637
from weblate_web.payments.models import Customer
37-
from weblate_web.payments.validators import cache_vies_data
3838

3939
if TYPE_CHECKING:
4040
from datetime import datetime
@@ -181,12 +181,17 @@ def fetch_vat_info(*, fetch_all: bool = False, delay: int = 30) -> None:
181181
customers = Customer.objects.exclude(vat="").exclude(vat=None)
182182
if not fetch_all:
183183
# Fetch data once a week
184+
# - entries without validation set once a week (never validated or during migration)
185+
# - validated entries two days before expiry to have two attempts to fetch the data
184186
weekday = timezone.now().weekday()
185-
customers = customers.annotate(idmod=F("id") % 7).filter(idmod=weekday)
187+
customers = customers.annotate(idmod=F("id") % 7).filter(
188+
(Q(idmod=weekday) & Q(vat_validated=None))
189+
| Q(vat_validity__gte=timezone.now() + timedelta(days=2))
190+
)
186191

187192
for customer in customers.iterator():
188193
# Avoid being rate limited at their side
189194
sleep(delay)
190195

191196
# Actually fetch data
192-
cache_vies_data(customer.vat, force=True)
197+
customer.validate_vatin(automated=True)

0 commit comments

Comments
 (0)