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

feat(backends): implement new invoices backend #2225

Merged
merged 1 commit into from
Oct 21, 2024
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
51 changes: 42 additions & 9 deletions weblate_web/invoices/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
from django.utils.functional import cached_property
from django.utils.translation import override
from fakturace.rates import DecimalRates
from weasyprint import CSS, HTML

Check failure on line 34 in weblate_web/invoices/models.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "weasyprint": module is installed, but missing library stubs or py.typed marker

Check failure on line 34 in weblate_web/invoices/models.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "weasyprint": module is installed, but missing library stubs or py.typed marker
from weasyprint.text.fonts import FontConfiguration

Check failure on line 35 in weblate_web/invoices/models.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "weasyprint.text.fonts": module is installed, but missing library stubs or py.typed marker

Check failure on line 35 in weblate_web/invoices/models.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "weasyprint.text.fonts": module is installed, but missing library stubs or py.typed marker

INVOICES_URL = "invoices:"
STATIC_URL = "static:"
Expand All @@ -41,6 +41,7 @@

def url_fetcher(url: str) -> dict[str, str | bytes]:
path_obj: Path
result: dict[str, str | bytes]
if url.startswith(INVOICES_URL):
path_obj = TEMPLATES_PATH / url.removeprefix(INVOICES_URL)
elif url.startswith(STATIC_URL):
Expand Down Expand Up @@ -132,7 +133,7 @@
def __str__(self) -> str:
return f"{self.number}: {self.customer} {self.total_amount}"

def save(
def save( # type: ignore[override]
self,
*,
force_insert: bool = False,
Expand Down Expand Up @@ -248,14 +249,21 @@

@property
def filename(self) -> str:
return f"Weblate {self.get_kind_display()} {self.number}.pdf"
return f"Weblate_{self.get_kind_display()}_{self.number}.pdf"

def generate_pdf(self):
@property
def path(self) -> Path:
return settings.INVOICES_PATH / self.filename

def generate_pdf(self) -> None:
# Create directory to store invoices
settings.INVOICES_PATH.mkdir(exist_ok=True)
font_config = FontConfiguration()

renderer = HTML(string=self.render_html(), url_fetcher=url_fetcher)
renderer = HTML(
string=self.render_html(),
url_fetcher=url_fetcher,
)
font_style = CSS(
string="""
@font-face {
Expand All @@ -278,6 +286,29 @@
font_config=font_config,
)

def finalize(
self, *, kind: InvoiceKindChoices = InvoiceKindChoices.INVOICE
) -> Invoice:
"""Create a final invoice from draft/proforma upon payment."""
invoice = Invoice.objects.create(

Check warning on line 293 in weblate_web/invoices/models.py

View check run for this annotation

Codecov / codecov/patch

weblate_web/invoices/models.py#L293

Added line #L293 was not covered by tests
kind=kind,
customer=self.customer,
customer_reference=self.customer_reference,
discount=self.discount,
vat_rate=self.vat_rate,
currency=self.currency,
parent=self,
prepaid=True,
)
for item in self.all_items:
invoice.invoiceitem_set.create(

Check warning on line 304 in weblate_web/invoices/models.py

View check run for this annotation

Codecov / codecov/patch

weblate_web/invoices/models.py#L304

Added line #L304 was not covered by tests
description=item.description,
quantity=item.quantity,
quantity_unit=item.quantity_unit,
unit_price=item.unit_price,
)
return invoice

Check warning on line 310 in weblate_web/invoices/models.py

View check run for this annotation

Codecov / codecov/patch

weblate_web/invoices/models.py#L310

Added line #L310 was not covered by tests


class InvoiceItem(models.Model):
invoice = models.ForeignKey(Invoice, on_delete=models.deletion.CASCADE)
Expand All @@ -294,22 +325,24 @@
return f"{self.description} ({self.display_quantity}) {self.display_price}"

@property
def display_price(self):
def display_price(self) -> str:
return self.invoice.render_amount(self.unit_price)

@property
def display_total_price(self):
def display_total_price(self) -> str:
return self.invoice.render_amount(self.unit_price * self.quantity)

def get_quantity_unit_display(self) -> str: # types: ignore[no-redef]
def get_quantity_unit_display(self) -> str: # type: ignore[no-redef]
# Correcly handle singulars
if self.quantity_unit == QuantityUnitChoices.HOURS and self.quantity == 1:
return "hour"
# This is what original get_quantity_unit_display() would have done
return self._get_FIELD_display(field=self._meta.get_field("quantity_unit"))
return self._get_FIELD_display( # type: ignore[attr-defined]
field=self._meta.get_field("quantity_unit")
)

@property
def display_quantity(self):
def display_quantity(self) -> str:
if self.quantity_unit:
return f"{self.quantity} {self.get_quantity_unit_display()}"
return f"{self.quantity}"
142 changes: 94 additions & 48 deletions weblate_web/payments/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,20 @@
import json
import re
import subprocess # noqa: S404
from decimal import Decimal
from hashlib import sha256
from math import floor

import fiobank

Check failure on line 27 in weblate_web/payments/backends.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "fiobank": module is installed, but missing library stubs or py.typed marker
import requests
import sentry_sdk
import thepay.config

Check failure on line 30 in weblate_web/payments/backends.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "thepay.config": module is installed, but missing library stubs or py.typed marker

Check failure on line 30 in weblate_web/payments/backends.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "thepay": module is installed, but missing library stubs or py.typed marker
import thepay.dataApi

Check failure on line 31 in weblate_web/payments/backends.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "thepay.dataApi": module is installed, but missing library stubs or py.typed marker
import thepay.gateApi

Check failure on line 32 in weblate_web/payments/backends.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "thepay.gateApi": module is installed, but missing library stubs or py.typed marker
import thepay.payment

Check failure on line 33 in weblate_web/payments/backends.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "thepay.payment": module is installed, but missing library stubs or py.typed marker
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction
from django.shortcuts import redirect
from django.utils.http import http_date
from django.utils.translation import get_language, gettext, gettext_lazy, override
Expand All @@ -41,6 +43,7 @@
from .utils import send_notification

BACKENDS = {}
# TODO: adjust RE to new proformas
PROFORMA_RE = re.compile("20[0-9]{7}")


Expand Down Expand Up @@ -146,10 +149,78 @@
self.failure()
return False

def generate_invoice(self, storage_class=InvoiceStorage, paid=True):
@transaction.atomic
def generate_invoice(self, *, proforma: bool = False, paid: bool = True):
from weblate_web.invoices.models import ( # noqa: PLC0415
CurrencyChoices,
Invoice,
InvoiceKindChoices,
)

if self.payment.paid_invoice:
raise ValueError("Invoice already exists!")

Check warning on line 161 in weblate_web/payments/backends.py

View check run for this annotation

Codecov / codecov/patch

weblate_web/payments/backends.py#L161

Added line #L161 was not covered by tests
invoice_kind = (
InvoiceKindChoices.PROFORMA if proforma else InvoiceKindChoices.INVOICE
)
if self.payment.draft_invoice:
# Finalize draft if present
self.payment.paid_invoice = self.payment.draft_invoice.finalize(

Check warning on line 167 in weblate_web/payments/backends.py

View check run for this annotation

Codecov / codecov/patch

weblate_web/payments/backends.py#L167

Added line #L167 was not covered by tests
kind=invoice_kind
)
else:
# Generate manually if no draft is present (hosted integration)
invoice = self.payment.paid_invoice = Invoice.objects.create(
kind=invoice_kind,
customer=self.payment.customer,
vat_rate=self.payment.customer.vat_rate,
currency=CurrencyChoices.EUR,
prepaid=True,
)
invoice.invoiceitem_set.create(
description=self.payment.description,
unit_price=round(Decimal(self.payment.amount_without_vat), 2),
)

# Generate PDF
invoice.generate_pdf()

# Update reference
self.payment.save(update_fields=["paid_invoice"])

def send_notification(self, notification, include_invoice=True):
kwargs = {"backend": self}
if self.invoice:
kwargs["invoice"] = self.invoice
if self.payment:
kwargs["payment"] = self.payment
send_notification(notification, [self.payment.customer.email], **kwargs)

def get_invoice_kwargs(self):
return {"payment_id": str(self.payment.pk), "payment_method": self.description}

def success(self):
self.payment.state = Payment.ACCEPTED
if not self.recurring:
self.payment.recurring = ""

self.generate_invoice()
self.payment.save()

self.send_notification("payment_completed")

def failure(self):
self.payment.state = Payment.REJECTED
self.payment.save()

self.send_notification("payment_failed")


class LegacyBackend(Backend):
def generate_invoice(self, *, proforma: bool = False, paid: bool = True):
"""Generate an invoice."""
if settings.PAYMENT_FAKTURACE is None:
return
raise ValueError("Fakturace storage is not configured!")

Check warning on line 222 in weblate_web/payments/backends.py

View check run for this annotation

Codecov / codecov/patch

weblate_web/payments/backends.py#L222

Added line #L222 was not covered by tests
storage_class = ProformaStorage if proforma else InvoiceStorage
storage = storage_class(settings.PAYMENT_FAKTURACE)
customer = self.payment.customer
customer_id = f"web-{customer.pk}"
Expand Down Expand Up @@ -201,33 +272,6 @@
cwd=settings.PAYMENT_FAKTURACE,
)

def send_notification(self, notification, include_invoice=True):
kwargs = {"backend": self}
if self.invoice:
kwargs["invoice"] = self.invoice
if self.payment:
kwargs["payment"] = self.payment
send_notification(notification, [self.payment.customer.email], **kwargs)

def get_invoice_kwargs(self):
return {"payment_id": str(self.payment.pk), "payment_method": self.description}

def success(self):
self.payment.state = Payment.ACCEPTED
if not self.recurring:
self.payment.recurring = ""

self.generate_invoice()
self.payment.save()

self.send_notification("payment_completed")

def failure(self):
self.payment.state = Payment.REJECTED
self.payment.save()

self.send_notification("payment_failed")


@register_backend
class DebugPay(Backend):
Expand Down Expand Up @@ -278,7 +322,7 @@


@register_backend
class ThePayCard(Backend):
class ThePayCard(LegacyBackend):
name = "thepay-card"
verbose = gettext_lazy("Payment card")
description = "Payment Card (The Pay)"
Expand Down Expand Up @@ -382,7 +426,8 @@


@register_backend
class FioBank(Backend):
class FioBank(LegacyBackend):
# TODO: migrate from legacy backend
name = "fio-bank"
verbose = gettext_lazy("IBAN bank transfer")
description = "Bank transfer"
Expand All @@ -395,7 +440,7 @@
return True

def perform(self, request, back_url, complete_url):
self.generate_invoice(storage_class=ProformaStorage, paid=False)
self.generate_invoice(proforma=True, paid=False)
self.payment.details["proforma"] = self.payment.invoice
self.send_notification("payment_pending")
return redirect(complete_url)
Expand Down Expand Up @@ -425,22 +470,23 @@
@classmethod
def fetch_payments(cls, from_date=None):
client = fiobank.FioBank(token=settings.FIO_TOKEN)
for transaction in client.last(from_date=from_date):
for entry in client.last(from_date=from_date):
matches = []
# Extract from message
if transaction["recipient_message"]:
matches.extend(PROFORMA_RE.findall(transaction["recipient_message"]))
if entry["recipient_message"]:
matches.extend(PROFORMA_RE.findall(entry["recipient_message"]))
# Extract from variable symbol
if transaction["variable_symbol"]:
matches.extend(PROFORMA_RE.findall(transaction["variable_symbol"]))
if entry["variable_symbol"]:
matches.extend(PROFORMA_RE.findall(entry["variable_symbol"]))
# Extract from sender reference
if transaction.get("reference", None):
matches.extend(PROFORMA_RE.findall(transaction["reference"]))
if entry.get("reference", None):
matches.extend(PROFORMA_RE.findall(entry["reference"]))

Check warning on line 483 in weblate_web/payments/backends.py

View check run for this annotation

Codecov / codecov/patch

weblate_web/payments/backends.py#L483

Added line #L483 was not covered by tests
# Extract from comment for manual pairing
if transaction["comment"]:
matches.extend(PROFORMA_RE.findall(transaction["comment"]))
if entry["comment"]:
matches.extend(PROFORMA_RE.findall(entry["comment"]))
# Process all matches
for proforma_number in matches:
# TODO: Fetch invoice object
proforma_id = f"P{proforma_number}"
try:
related = Payment.objects.get(backend=cls.name, invoice=proforma_id)
Expand All @@ -453,31 +499,31 @@
backend = cls(related)
proforma = backend.get_proforma()
proforma.mark_paid(
json.dumps(transaction, indent=2, cls=DjangoJSONEncoder)
json.dumps(entry, indent=2, cls=DjangoJSONEncoder)
)
backend.git_commit([proforma.paid_path], proforma)
if floor(float(proforma.total_amount)) <= transaction["amount"]:
if floor(float(proforma.total_amount)) <= entry["amount"]:
print(f"Received payment for {proforma_id}")
backend.payment.details["transaction"] = transaction
backend.payment.details["transaction"] = entry
backend.success()
else:
print(
"Underpaid {}: received={}, expected={}".format(
proforma_id,
transaction["amount"],
proforma.total_amount,
proforma_id, entry["amount"], proforma.total_amount
)
)
except Payment.DoesNotExist:
print(f"No matching payment for {proforma_id} found")


# @register_backend
@register_backend
class ThePay2Card(Backend):
name = "thepay2-card"
verbose = gettext_lazy("Payment card")
description = "Payment Card (The Pay)"
recurring = True
# TODO: make it production
debug = True

def get_headers(self) -> dict[str, str]:
timestamp = http_date()
Expand Down
4 changes: 2 additions & 2 deletions weblate_web/payments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import os.path
import uuid

from appconf import AppConf

Check failure on line 25 in weblate_web/payments/models.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "appconf": module is installed, but missing library stubs or py.typed marker

Check failure on line 25 in weblate_web/payments/models.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "appconf": module is installed, but missing library stubs or py.typed marker
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.exceptions import ValidationError
Expand All @@ -32,7 +32,7 @@
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy, pgettext_lazy
from django_countries.fields import CountryField
from vies.models import VATINField

Check failure on line 35 in weblate_web/payments/models.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "vies.models": module is installed, but missing library stubs or py.typed marker

Check failure on line 35 in weblate_web/payments/models.py

View workflow job for this annotation

GitHub Actions / mypy

Skipping analyzing "vies.models": module is installed, but missing library stubs or py.typed marker

from .fields import Char32UUIDField
from .utils import validate_email
Expand Down Expand Up @@ -177,11 +177,11 @@
return self.country_code in EU_VAT_RATES and not self.vat

@property
def needs_vat(self):
def needs_vat(self) -> bool:
return self.vat_country_code == "CZ" or self.is_eu_enduser

@property
def vat_rate(self):
def vat_rate(self) -> int:
if self.needs_vat:
return VAT_RATE
return 0
Expand Down
10 changes: 10 additions & 0 deletions weblate_web/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,16 @@ def download_invoice(request, pk):
):
raise Http404("Invoice not accessible to current user!")

# New invoice model
if payment.paid_invoice:
return FileResponse(
payment.paid_invoice.path.open("rb"),
as_attachment=True,
filename=payment.paid_invoice.filename,
content_type="application/pdf",
)

# Legacy payments storage
if not payment.invoice_filename_valid:
raise Http404(f"File {payment.invoice_filename} does not exist!")

Expand Down
Loading