diff --git a/deployment/docker/pretix.bash b/deployment/docker/pretix.bash index 24876de9f..c5a14a1c7 100644 --- a/deployment/docker/pretix.bash +++ b/deployment/docker/pretix.bash @@ -46,6 +46,11 @@ if [ "$1" == "taskworker" ]; then exec celery -A pretix.celery_app worker -l info "$@" fi +if [ "$1" == "taskbeat" ]; then + shift + exec celery -A pretix.celery_app beat -l info "$@" +fi + if [ "$1" == "upgrade" ]; then exec python3 -m pretix updatestyles fi diff --git a/deployment/docker/supervisord/pretixbeat.conf b/deployment/docker/supervisord/pretixbeat.conf new file mode 100644 index 000000000..0d2fd29f0 --- /dev/null +++ b/deployment/docker/supervisord/pretixbeat.conf @@ -0,0 +1,10 @@ +[program:pretixtask] +command=/usr/local/bin/pretix taskbeat +autostart=true +autorestart=true +priority=5 +user=pretixuser +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index f38b0139d..0e1655c7d 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -6,10 +6,12 @@ from pretix.api.views import cart +from ..eventyay_common.views.billing import BillingInvoicePreview from .views import ( checkin, device, event, exporters, item, oauth, order, organizer, upload, user, version, voucher, waitinglist, webhooks, ) +from .views.stripe import stripe_webhook_view router = routers.DefaultRouter() router.register(r'organizers', organizer.OrganizerViewSet) @@ -98,6 +100,8 @@ url(r"^upload$", upload.UploadView.as_view(), name="upload"), url(r"^me$", user.MeView.as_view(), name="user.me"), url(r"^version$", version.VersionView.as_view(), name="version"), + url(r"^billing-testing/(?P[^/]+)", BillingInvoicePreview.as_view(), name="billing-testing"), + url(r'^webhook/stripe$', stripe_webhook_view, name='stripe-webhook'), url(r"(?P[^/]+)/(?P[^/]+)/schedule-public", event.talk_schedule_public, name="event.schedule-public"), url(r"(?P[^/]+)/(?P[^/]+)/ticket-check", event.CustomerOrderCheckView.as_view(), diff --git a/src/pretix/api/views/stripe.py b/src/pretix/api/views/stripe.py new file mode 100644 index 000000000..fe98ea6d2 --- /dev/null +++ b/src/pretix/api/views/stripe.py @@ -0,0 +1,37 @@ +import logging + +import stripe +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt + +from pretix.eventyay_common.tasks import update_billing_invoice_information +from pretix.helpers.stripe_utils import ( + get_stripe_secret_key, get_stripe_webhook_secret_key, +) + +logger = logging.getLogger(__name__) + + +@csrf_exempt +def stripe_webhook_view(request): + stripe.api_key = get_stripe_secret_key() + payload = request.body + webhook_secret_key = get_stripe_webhook_secret_key() + sig_header = request.META['HTTP_STRIPE_SIGNATURE'] + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, webhook_secret_key + ) + except ValueError as e: + logger.error("Error parsing payload: %s", str(e)) + return HttpResponse("Invalid payload", status=400) + except stripe.error.SignatureVerificationError as e: + logger.error("Error verifying webhook signature: %s", str(e)) + return HttpResponse("Invalid signature", status=400) + + if event.type == 'payment_intent.succeeded': + invoice_id = event.data.object.get('metadata', {}).get('invoice_id') + update_billing_invoice_information.delay(invoice_id=invoice_id) + + return HttpResponse("Success", status=200) diff --git a/src/pretix/base/migrations/0004_create_billing_invoice.py b/src/pretix/base/migrations/0004_create_billing_invoice.py new file mode 100644 index 000000000..515226ae0 --- /dev/null +++ b/src/pretix/base/migrations/0004_create_billing_invoice.py @@ -0,0 +1,78 @@ +# Generated by Django 5.1.3 on 2024-11-19 04:50 + +import datetime + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "pretixbase", + "0003_alter_cachedcombinedticket_id_alter_cachedticket_id_and_more", + ), + ] + + operations = [ + migrations.CreateModel( + name="BillingInvoice", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("status", models.CharField(default="n", max_length=1)), + ("amount", models.DecimalField(decimal_places=2, max_digits=10)), + ("currency", models.CharField(max_length=3)), + ("ticket_fee", models.DecimalField(decimal_places=2, max_digits=10)), + ("payment_method", models.CharField(max_length=20, null=True)), + ("paid_datetime", models.DateTimeField(blank=True, null=True)), + ("note", models.TextField(null=True)), + ("monthly_bill", models.DateField(default=datetime.date.today)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("created_by", models.CharField(max_length=50)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("updated_by", models.CharField(max_length=50)), + ("last_reminder_datetime", models.DateTimeField(blank=True, null=True)), + ("next_reminder_datetime", models.DateTimeField(blank=True, null=True)), + ( + "reminder_schedule", + django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField(), default=list, size=None + ), + ), + ("reminder_enabled", models.BooleanField(default=True)), + ( + "stripe_payment_intent_id", + models.CharField(max_length=50, null=True), + ), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="pretixbase.event", + ), + ), + ( + "organizer", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="pretixbase.organizer", + ), + ), + ], + options={ + "verbose_name": "Billing Invoice", + "verbose_name_plural": "Billing Invoices", + "ordering": ("-created_at",), + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index c282f5ada..f40dd773b 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -1,6 +1,7 @@ from ..settings import GlobalSettingsObject_SettingsStore from .auth import U2FDevice, User, WebAuthnDevice from .base import CachedFile, LoggedModel, cachedfile_name +from .billing import BillingInvoice from .checkin import Checkin, CheckinList from .devices import Device, Gate from .event import ( diff --git a/src/pretix/base/models/billing.py b/src/pretix/base/models/billing.py new file mode 100644 index 000000000..fe6ad112e --- /dev/null +++ b/src/pretix/base/models/billing.py @@ -0,0 +1,59 @@ +from datetime import date + +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_scopes import ScopedManager + +from pretix.base.models import LoggedModel + + +class BillingInvoice(LoggedModel): + STATUS_PENDING = "n" + STATUS_PAID = "p" + STATUS_EXPIRED = "e" + STATUS_CANCELED = "c" + + STATUS_CHOICES = [ + (STATUS_PENDING, _("pending")), + (STATUS_PAID, _("paid")), + (STATUS_EXPIRED, _("expired")), + (STATUS_CANCELED, _("canceled")), + ] + + organizer = models.ForeignKey('Organizer', on_delete=models.CASCADE) + # organizer_billing = models.ForeignKey('OrganizerBilling', on_delete=models.CASCADE) + event = models.ForeignKey('Event', on_delete=models.CASCADE) + + status = models.CharField(max_length=1, choices=STATUS_CHOICES, default=STATUS_PENDING) + amount = models.DecimalField(max_digits=10, decimal_places=2) + currency = models.CharField(max_length=3) + + ticket_fee = models.DecimalField(max_digits=10, decimal_places=2) + payment_method = models.CharField(max_length=20, null=True, blank=True) + paid_datetime = models.DateTimeField(null=True, blank=True) + note = models.TextField(null=True, blank=True) + + monthly_bill = models.DateField(default=date.today) + created_at = models.DateTimeField(auto_now_add=True) + created_by = models.CharField(max_length=50) + updated_at = models.DateTimeField(auto_now=True) + updated_by = models.CharField(max_length=50) + + last_reminder_datetime = models.DateTimeField(null=True, blank=True) + next_reminder_datetime = models.DateTimeField(null=True, blank=True) + reminder_schedule = ArrayField( + models.IntegerField(), + default=list, # Sets the default to an empty list + blank=True, + help_text="Days after creation for reminders, e.g., [14, 28]" + ) + reminder_enabled = models.BooleanField(default=True) + stripe_payment_intent_id = models.CharField(max_length=50, null=True, blank=True) + + objects = ScopedManager(organizer='organizer') + + class Meta: + verbose_name = "Billing Invoice" + verbose_name_plural = "Billing Invoices" + ordering = ("-created_at",) diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index ed14610bf..469ce6198 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -672,7 +672,7 @@ def get_mail_backend(self, timeout=None, force_custom=False): if gs.settings.email_vendor == "sendgrid": return SendGridEmail(api_key=gs.settings.send_grid_api_key) else: - CustomSMTPBackend( + return CustomSMTPBackend( host=gs.settings.smtp_host, port=gs.settings.smtp_port, username=gs.settings.smtp_username, diff --git a/src/pretix/base/services/mail.py b/src/pretix/base/services/mail.py index 563392020..74e1bd8f0 100644 --- a/src/pretix/base/services/mail.py +++ b/src/pretix/base/services/mail.py @@ -1,3 +1,4 @@ +import base64 import inspect import logging import os @@ -35,6 +36,7 @@ from pretix.base.services.invoices import invoice_pdf_task from pretix.base.services.tasks import TransactionAwareTask from pretix.base.services.tickets import get_tickets_for_order +from pretix.base.settings import GlobalSettingsObject from pretix.base.signals import email_filter, global_email_filter from pretix.celery_app import app from pretix.multidomain.urlreverse import build_absolute_uri @@ -277,7 +279,8 @@ def _create_mime_attachment(self, content, mimetype): def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str, event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None, invoices: List[int] = None, order: int = None, attach_tickets=False, user=None, - attach_ical=False, attach_cached_files: List[int] = None) -> bool: + attach_ical=False, attach_cached_files: List[int] = None, attach_file_base64: str = None, + attach_file_name: str = None) -> bool: email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers) if html is not None: html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET) @@ -295,7 +298,7 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st backend = event.get_mail_backend() def cm(): return scope(organizer=event.organizer) # noqa else: - backend = get_connection(fail_silently=False) + backend = get_mail_backend() def cm(): return scopes_disabled() # noqa with cm(): @@ -385,6 +388,9 @@ def cm(): return scopes_disabled() # noqa pass email = global_email_filter.send_chained(event, 'message', message=email, user=user, order=order) + if attach_file_base64: + attach_file_content = base64.b64decode(attach_file_base64) + email.attach(attach_file_name, attach_file_content, "application/pdf") try: backend.send_messages([email]) @@ -592,3 +598,30 @@ def normalize_image_url(url): else: url = urljoin(settings.MEDIA_URL, url) return url + + +def get_mail_backend(timeout=None): + """ + Returns an email server connection, either by using the system-wide connection + or by returning a custom one based on the system's settings. + """ + from pretix.base.email import CustomSMTPBackend, SendGridEmail + + gs = GlobalSettingsObject() + + if gs.settings.email_vendor is not None: + if gs.settings.email_vendor == "sendgrid": + return SendGridEmail(api_key=gs.settings.send_grid_api_key) + else: + return CustomSMTPBackend( + host=gs.settings.smtp_host, + port=gs.settings.smtp_port, + username=gs.settings.smtp_username, + password=gs.settings.smtp_password, + use_tls=gs.settings.smtp_use_tls, + use_ssl=gs.settings.smtp_use_ssl, + fail_silently=False, + timeout=timeout, + ) + else: + return get_connection(fail_silently=False) diff --git a/src/pretix/control/forms/global_settings.py b/src/pretix/control/forms/global_settings.py index e57f92c29..a296b7a5c 100644 --- a/src/pretix/control/forms/global_settings.py +++ b/src/pretix/control/forms/global_settings.py @@ -140,6 +140,12 @@ def __init__(self, *args, **kwargs): self.fields['banner_message'].widget.attrs['rows'] = '2' self.fields['banner_message_detail'].widget.attrs['rows'] = '3' + self.fields = OrderedDict(list(self.fields.items()) + [ + ('stripe_webhook_secret_key', SecretKeySettingsField( + label=_('Stripe Webhook: Secret key'), + required=False, + )), + ]) class UpdateSettingsForm(SettingsForm): diff --git a/src/pretix/eventyay_common/billing_invoice.py b/src/pretix/eventyay_common/billing_invoice.py new file mode 100644 index 000000000..4bc41c9db --- /dev/null +++ b/src/pretix/eventyay_common/billing_invoice.py @@ -0,0 +1,238 @@ +from datetime import datetime, timezone +from io import BytesIO + +from django.http import FileResponse +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.platypus import ( + HRFlowable, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, +) + + +def generate_invoice_pdf(billing_invoice, organizer_billing_info): + buffer = BytesIO() + doc = SimpleDocTemplate(buffer, pagesize=A4) + elements = [] + getSampleStyleSheet() + + # Custom Styles with Adjusted Font Sizes + no_style = ParagraphStyle( + name="NoStyle", + fontSize=8, + alignment=2, + leftIndent=0, + rightIndent=0, + textColor=colors.HexColor("#333333"), + ) + title_style = ParagraphStyle( + name="TitleStyle", + fontSize=20, + alignment=1, + leftIndent=0, + rightIndent=0, + textColor=colors.HexColor("#333333"), + ) + body_text_style = ParagraphStyle( + name="BodyTextStyle", + fontSize=10, + alignment=2, + rightIndent=0, + textColor=colors.HexColor("#333333"), + ) # Uniform font size for other text + header_style = ParagraphStyle( + name="HeaderStyle", + fontSize=10, + alignment=0, + textColor=colors.HexColor("#27aae1"), + ) + row_header_style = ParagraphStyle( + name="HeaderStyle", + fontSize=10, + alignment=0, + textColor=colors.HexColor("#ffffff"), + ) + bold_style = ParagraphStyle( + name="BoldStyle", + fontSize=10, + leading=12, + fontName="Helvetica-Bold", + alignment=0, + ) + footer_style = ParagraphStyle( + name="TitleStyle", + fontSize=10, + alignment=0, + textColor=colors.HexColor("#333333"), + ) + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + # Header Table: Logo on the left, title and info in a stacked format on the right + header_data = [ + [ + Table( + [ + [ + Paragraph( + f"NO. {billing_invoice.id}", + no_style, + ) + ], + [ + Paragraph( + f"{str(billing_invoice.event.name)} invoice", + title_style, + ) + ], + [Paragraph("
", title_style)], + [Paragraph("
", title_style)], + [Paragraph("
", title_style)], + [ + Paragraph( + f"Date: {today}
", + body_text_style, + ) + ], + ], + colWidths=[6.5 * inch], + ) + ] + ] + header_table = Table(header_data, colWidths=[1.5 * inch, 4.5 * inch]) + header_table.setStyle( + TableStyle( + [ + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("LEFTPADDING", (0, 0), (-1, -1), 0), + ("RIGHTPADDING", (0, 0), (-1, -1), 0), + ] + ) + ) + elements.append(header_table) + + # Line Break (Separator) + elements.append(Spacer(1, 0.3 * inch)) + elements.append( + HRFlowable(width="120%", thickness=1, color=colors.HexColor("#27aae1")) + ) + elements.append(Spacer(1, 0.3 * inch)) + + # Invoice Information Table + invoice_data = [ + [ + Paragraph("INVOICE FROM:", header_style), + "", + "", + Paragraph("INVOICE TO:", header_style), + ], + [ + Paragraph("Eventyay's name", bold_style), + "", + "", + Paragraph(f"{organizer_billing_info.primary_contact_name}", bold_style), + ], + [f"{organizer_billing_info.address_line_1}", "", "", ""], + [ + "City, Country, ZIP", + "", + "", + f"{organizer_billing_info.city}, {organizer_billing_info.country}, {organizer_billing_info.zip_code}", + ], + ] + invoice_table = Table( + invoice_data, colWidths=[1.5 * inch, 2 * inch, 1.5 * inch, 1.5 * inch] + ) + invoice_table.setStyle( + TableStyle( + [ + ("FONTNAME", (0, 0), (-1, -1), "Helvetica"), + ("FONTSIZE", (0, 0), (-1, -1), 10), + ("TEXTCOLOR", (0, 0), (-1, -1), colors.black), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ] + ) + ) + elements.append(invoice_table) + elements.append(Spacer(1, 0.3 * inch)) + + # Itemized Table with Header Background + item_data = [ + [ + Paragraph("#", row_header_style), + Paragraph("DESCRIPTION", row_header_style), + Paragraph("PRICE", row_header_style), + Paragraph("QUANTITY", row_header_style), + Paragraph("AMOUNT", row_header_style), + ], + [ + "1", + "Ticket fee for " + f"{billing_invoice.monthly_bill.strftime('%B %Y')}", + f"{billing_invoice.ticket_fee} {billing_invoice.currency}", + "1", + f"{billing_invoice.ticket_fee} {billing_invoice.currency}", + ], + ] + item_table = Table( + item_data, colWidths=[0.5 * inch, 3 * inch, 1 * inch, 1 * inch, 1 * inch] + ) + item_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#27aae1")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("ALIGN", (0, 0), (-1, -1), "CENTER"), + ("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"), + ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), + ("BACKGROUND", (0, 1), (-1, -1), colors.whitesmoke), + ] + ) + ) + elements.append(item_table) + elements.append(Spacer(1, 0.3 * inch)) + + # Footer Totals Section + totals_data = [ + ["SUBTOTAL", f"{billing_invoice.ticket_fee}"], + ["TAX", "0"], + [ + Paragraph("GRAND TOTAL", bold_style), + Paragraph( + f"{billing_invoice.ticket_fee} {billing_invoice.currency}", + bold_style, + ), + ], + ] + totals_table = Table(totals_data, colWidths=[5 * inch, 1.5 * inch]) + totals_table.setStyle( + TableStyle( + [ + ("ALIGN", (0, 0), (-1, -1), "RIGHT"), + ("TEXTCOLOR", (0, -1), (-1, -1), colors.HexColor("#27aae1")), + ("LINEABOVE", (0, 0), (-1, 0), 0.5, colors.grey), + ("LINEBELOW", (0, -1), (-1, -1), 1, colors.HexColor("#27aae1")), + ] + ) + ) + elements.append(totals_table) + elements.append(Spacer(1, 0.3 * inch)) + + elements.append(Spacer(1, 0.3 * inch)) + notice = f""" + NOTICE:
+ - Payment due within 30 days of the invoice date.
+ - Your event {str(billing_invoice.event.name)} will be moved to non-public if payment is not received + within 30 days.""" + + elements.append(Paragraph(notice, footer_style)) + + # Build PDF + doc.build(elements) + buffer.seek(0) + + return FileResponse( + buffer, as_attachment=True, + filename=f"invoice_{billing_invoice.event.slug}_{billing_invoice.monthly_bill}.pdf" + ) diff --git a/src/pretix/eventyay_common/tasks.py b/src/pretix/eventyay_common/tasks.py index b88aab5f6..9037a50a0 100644 --- a/src/pretix/eventyay_common/tasks.py +++ b/src/pretix/eventyay_common/tasks.py @@ -1,11 +1,29 @@ +import base64 import logging +from datetime import datetime, timezone as tz +from decimal import Decimal +import pytz import requests from celery import shared_task +from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import DatabaseError +from django.db.models import Q +from django_scopes import scopes_disabled +from pretix.helpers.stripe_utils import ( + confirm_payment_intent, process_auto_billing_charge_stripe, +) + +from ..base.models import BillingInvoice, Event, Order, Organizer +from ..base.models.organizer import OrganizerBillingModel +from ..base.services.mail import mail_send_task +from ..base.settings import GlobalSettingsObject from ..helpers.jwt_generate import generate_sso_token +from .billing_invoice import generate_invoice_pdf logger = logging.getLogger(__name__) @@ -117,21 +135,21 @@ def send_event_webhook(self, user_id, event, action): ) # Retries up to 5 times with a 60-second delay def create_world(self, is_video_creation, event_data): """ - Create a video system for the specified event. + Create a video system for the specified event. - :param self: Task instance - :param is_video_creation: A boolean indicating whether the user has chosen to add a video. - :param event_data: A dictionary containing the following event details: - - id (str): The unique identifier for the event. - - title (str): The title of the event. - - timezone (str): The timezone in which the event takes place. - - locale (str): The locale for the event. - - token (str): Authorization token for making the request. - - has_permission (bool): Indicates if the user has 'can_create_events' permission or is in admin session mode. + :param self: Task instance + :param is_video_creation: A boolean indicating whether the user has chosen to add a video. + :param event_data: A dictionary containing the following event details: + - id (str): The unique identifier for the event. + - title (str): The title of the event. + - timezone (str): The timezone in which the event takes place. + - locale (str): The locale for the event. + - token (str): Authorization token for making the request. + - has_permission (bool): Indicates if the user has 'can_create_events' permission or is in admin session mode. - To successfully create a world, both conditions must be satisfied: - - The user must have the necessary permission. - - The user must choose to create a video. + To successfully create a world, both conditions must be satisfied: + - The user must have the necessary permission. + - The user must choose to create a video. """ event_slug = event_data.get("id") title = event_data.get("title") @@ -180,3 +198,417 @@ def get_header_token(user_id): "Content-Type": "application/json", } return headers + + +@shared_task( + bind=True, max_retries=5, default_retry_delay=60 +) # Retries up to 5 times with a 60-second delay +def monthly_billing_collect(self): + """ + Collect billing on a monthly basis for all events + schedule on 1st day of the month and collect billing for the previous month + @param self: task instance + """ + try: + today = datetime.today() + first_day_of_current_month = today.replace(day=1) + logger.info( + "Start - running task to collect billing on: %s", first_day_of_current_month + ) + # Get the last month by subtracting one month from today + last_month_date = (first_day_of_current_month - relativedelta(months=1)).date() + gs = GlobalSettingsObject() + ticket_rate = gs.settings.get("ticket_fee_percentage") or 2.5 + organizers = Organizer.objects.all() + for organizer in organizers: + events = Event.objects.filter(organizer=organizer) + for event in events: + try: + logger.info("Collecting billing data for event: %s", event.name) + billing_invoice = BillingInvoice.objects.filter( + event=event, monthly_bill=last_month_date, organizer=organizer + ) + if billing_invoice: + logger.info( + "Billing invoice already created for event: %s", event.name + ) + continue + # Continue if event order count on last month = 0 + if event.orders.filter( + status=Order.STATUS_PAID, + datetime__range=[ + last_month_date, + (last_month_date + relativedelta(months=1, day=1)) - relativedelta(days=1), + ], + ).count() == 0: + logger.info( + "No paid orders for event: %s in the last month", event.name + ) + continue + + total_amount = calculate_total_amount_on_monthly( + event, last_month_date + ) + tickets_fee = calculate_ticket_fee(total_amount, ticket_rate) + # Create a new billing invoice + billing_invoice = BillingInvoice( + organizer=organizer, + event=event, + amount=total_amount, + currency=event.currency, + ticket_fee=tickets_fee, + monthly_bill=last_month_date, + reminder_schedule=settings.BILLING_REMINDER_SCHEDULE, + created_at=today, + created_by=settings.PRETIX_EMAIL_NONE_VALUE, + updated_at=today, + updated_by=settings.PRETIX_EMAIL_NONE_VALUE, + ) + billing_invoice.next_reminder_datetime = get_next_reminder_datetime( + settings.BILLING_REMINDER_SCHEDULE + ) + billing_invoice.save() + except Exception as e: + # If unexpected error happened, skip the event and continue to the next one + logger.error( + "Unexpected error happen when trying to collect billing for event: %s", + event.slug, + ) + logger.error("Error: %s", e) + continue + logger.info("End - completed task to collect billing on a monthly basis.") + except DatabaseError as e: + logger.error("Database error when trying to collect billing: %s", e) + # Retry the task if an exception occurs (with exponential backoff by default) + try: + self.retry(exc=e) + except self.MaxRetriesExceededError: + logger.error("Max retries exceeded for billing collect.") + except Exception as e: + logger.error("Unexpected error happen when trying to collect billing: %s", e) + # Retry the task if an exception occurs (with exponential backoff by default) + try: + self.retry(exc=e) + except self.MaxRetriesExceededError: + logger.error("Max retries exceeded for billing collect.") + + +@shared_task() +def update_billing_invoice_information(invoice_id: str): + """ + Update billing invoice information after payment is succeeded + @param invoice_id: A string representing the invoice ID + """ + try: + if not invoice_id: + logger.error("Missing invoice_id in Stripe webhook metadata") + return None + with scopes_disabled(): + invoice_information_updated = BillingInvoice.objects.filter( + id=invoice_id, + ).update( + status=BillingInvoice.STATUS_PAID, + paid_datetime=datetime.now(), + payment_method='stripe', + updated_at=datetime.now(), + reminder_enabled=False + ) + if not invoice_information_updated: + logger.error("Invoice not found or already updated: %s", invoice_id) + return None + logger.info("Payment succeeded for invoice: %s", invoice_id) + except BillingInvoice.DoesNotExist as e: + logger.error("Invoice not found in database: %s", str(e)) + return None + except DatabaseError as e: + logger.error("Database error updating invoice: %s", str(e)) + return None + + +def retry_payment(payment_intent_id, organizer_id): + """ + Retry a payment if the initial charge attempt failed. + @param payment_intent_id: A string representing the payment intent ID + @param organizer_id: A string representing the organizer's unique ID + """ + try: + billing_settings = OrganizerBillingModel.objects.filter( + organizer_id=organizer_id + ).first() + if not billing_settings or not billing_settings.stripe_payment_method_id: + logger.error( + "No billing settings or Stripe payment method ID found for organizer %s", organizer_id + ) + return + confirm_payment_intent(payment_intent_id, billing_settings.stripe_payment_method_id) + logger.info("Payment confirmed for payment intent: %s", payment_intent_id) + except ValidationError as e: + logger.error("Error retrying payment for %s: %s", payment_intent_id, str(e)) + + +@shared_task() +def process_auto_billing_charge(): + """ + Process auto billing charge + - If the ticket fee is greater than 0, the monthly bill is from the previous month, and the status is "pending" (n), + the system will process the auto-billing charge for that invoice. + - This task is scheduled to run on the 1st day of each month. + """ + try: + today = datetime.today() + first_day_of_current_month = today.replace(day=1) + last_month_date = (first_day_of_current_month - relativedelta(months=1)).date() + pending_invoices = BillingInvoice.objects.filter(Q(monthly_bill=last_month_date) & Q(status='n')) + for invoice in pending_invoices: + if invoice.ticket_fee > 0: + + billing_settings = OrganizerBillingModel.objects.filter(organizer_id=invoice.organizer_id).first() + if not billing_settings or not billing_settings.stripe_customer_id: + logger.error("No billing settings or Stripe customer ID found for organizer %s", + invoice.organizer.slug) + continue + if not billing_settings.stripe_payment_method_id: + logger.error("No billing settings or Stripe payment method ID found for organizer %s", + invoice.organizer.slug) + continue + + metadata = { + 'event_id': invoice.event_id, + 'invoice_id': invoice.id, + 'monthly_bill': invoice.monthly_bill, + 'organizer_id': invoice.organizer_id, + } + process_auto_billing_charge_stripe(billing_settings.organizer.slug, + invoice.ticket_fee, currency=invoice.currency, + metadata=metadata, invoice_id=invoice.id) + else: + logger.info("No ticket fee for event: %s", invoice.event.slug) + continue + except ValidationError as e: + logger.error('Error happen when trying to process auto billing charge: %s', e) + + +def calculate_total_amount_on_monthly(event, last_month_date_start): + """ + Calculate the total amount of all paid orders for the event in the previous month + @param event: event to be calculated + @param last_month_date_start: start date of month to be calculated + @return: total amount of all paid orders for the event in the previous month + """ + # Calculate the end date for last month + last_month_date_end = ( + last_month_date_start + relativedelta(months=1, day=1) + ) - relativedelta(days=1) + + # Use aggregate to sum the total of all paid orders within the date range + total_amount = sum( + order.net_total for order in event.orders.filter( + status=Order.STATUS_PAID, + datetime__range=[last_month_date_start, last_month_date_end], + ) + ) or 0 # Return 0 if the result is None + + return total_amount + + +def calculate_ticket_fee(amount, rate): + """ + Calculate the ticket fee based on the amount and rate + @param amount: amount + @param rate: rate in percentage + @return: ticket fee + """ + return amount * (Decimal(rate) / 100) + + +def get_next_reminder_datetime(reminder_schedule): + """ + Get the next reminder datetime based on the reminder schedule + @param reminder_schedule: + @return: + """ + reminder_schedule.sort() + today = datetime.now() + # Find the next scheduled day in the current month + next_reminder = None + for day in reminder_schedule: + # Create a datetime object for each scheduled + reminder_date = datetime(today.year, today.month, day) + # Check if the scheduled day is in the future + if reminder_date > today: + next_reminder = reminder_date + break + if not next_reminder: + # Handle month wrapping (December to January) + next_month = today.month + 1 if today.month < 12 else 1 + next_year = today.year if today.month < 12 else today.year + 1 + # Select the first date in BILLING_REMIND_SCHEDULE for the next month + next_reminder = datetime(next_year, next_month, reminder_schedule[0]) + + return next_reminder + + +@shared_task(bind=True) +def billing_invoice_notification(self): + """ + Send billing invoice notification to organizers + @param self: task instance + """ + logger.info("Start - running task to send billing invoice notification.") + today = datetime.today() + first_day_of_current_month = today.replace(day=1) + billing_month = (first_day_of_current_month - relativedelta(months=1)).date() + last_month_invoices = BillingInvoice.objects.filter(monthly_bill=billing_month) + for invoice in last_month_invoices: + # Get organizer's contact details + organizer_billing = OrganizerBillingModel.objects.filter( + organizer=invoice.organizer + ).first() + if not organizer_billing: + logger.error("No billing settings found for organizer %s", invoice.organizer.name) + continue + month_name = invoice.monthly_bill.strftime("%B") + # Send email to organizer with invoice pdf + mail_subject = f"{month_name} invoice for {invoice.event.name}" + mail_content = (f"Dear {organizer_billing.primary_contact_name},\n\n" + f"Thank you for using our services! " + f"Please find attached for a summary of your invoice for {month_name}.\n\n" + f"Best regards,\n" + f"EventYay Team") + + billing_invoice_send_email( + mail_subject, mail_content, invoice, organizer_billing + ) + logger.info("End - completed task to send billing invoice notification.") + + +@shared_task(bind=True) +def retry_failed_payment(self): + pending_invoices = BillingInvoice.objects.filter( + status=BillingInvoice.STATUS_PENDING + ) + today = datetime.now(tz.utc) + logger.info( + "Start - running task to retry failed payment: %s", today + ) + timezone = pytz.timezone(settings.TIME_ZONE) + for invoice in pending_invoices: + if invoice.ticket_fee <= 0: + continue + reminder_dates = invoice.reminder_schedule + if not reminder_dates or not invoice.stripe_payment_intent_id: + continue + reminder_dates.sort() + for reminder_date in reminder_dates: + reminder_date = datetime(today.year, today.month, reminder_date) + reminder_date = timezone.localize(reminder_date) + if ( + not invoice.last_reminder_datetime + or invoice.last_reminder_datetime < reminder_date + ) and reminder_date <= today: + + retry_payment(payment_intent_id=invoice.stripe_payment_intent_id, organizer_id=invoice.organizer_id) + logger.info("Payment is retried for event %s", invoice.event.name) + + break + logger.info("End - completed task to retry failed payment.") + + +@shared_task(bind=True) +def check_billing_status_for_warning(self): + pending_invoices = BillingInvoice.objects.filter( + status=BillingInvoice.STATUS_PENDING, reminder_enabled=True + ) + today = datetime.now(tz.utc) + logger.info( + "Start - running task to check billing status for warning on: %s", today + ) + timezone = pytz.timezone(settings.TIME_ZONE) + for invoice in pending_invoices: + if invoice.ticket_fee <= 0: + continue + reminder_dates = invoice.reminder_schedule # [15, 29] + if not reminder_dates: + continue + reminder_dates.sort() + # marked invoice as expired if the due date is passed + if today > (invoice.created_at + relativedelta(months=1)): + logger.info("Invoice is expired for event %s", invoice.event.name) + invoice.status = BillingInvoice.STATUS_EXPIRED + invoice.reminder_enabled = False + invoice.save() + # TODO: move event's status to non-public + continue + for reminder_date in reminder_dates: + reminder_date = datetime(today.year, today.month, reminder_date) + reminder_date = timezone.localize(reminder_date) + if ( + not invoice.last_reminder_datetime + or invoice.last_reminder_datetime < reminder_date + ) and reminder_date <= today: + # Send warning email to organizer + logger.info( + "Warning email is send to the organizer of %s", + invoice.event.slug, + ) + + # Get organizer's contact details + organizer_billing = OrganizerBillingModel.objects.filter( + organizer=invoice.organizer + ).first() + if not organizer_billing: + logger.error( + "No billing settings found for organizer %s", + invoice.organizer.name, + ) + break + month_name = invoice.monthly_bill.strftime("%B") + + mail_subject = f"Warning: {month_name} invoice for {invoice.event.name} need to be paid" + mail_content = ( + f"Dear {organizer_billing.primary_contact_name},\n\n" + f"This is a gentle reminder that your invoice for {month_name} is still pending " + f"and is due for payment soon. We value your prompt attention to this matter " + f"to ensure continued service without interruption.\n\n" + f"Invoice Details:\n" + f"- Invoice Date: {invoice.monthly_bill}\n" + f"- Due Date: {invoice.created_at + relativedelta(months=1)} \n" + f"- Total Amount Due: {invoice.ticket_fee} {invoice.currency}\n\n" + f"If you have already made the payment, please disregard this notice. " + f"However, if you need additional time or have any questions, " + f"feel free to reach out to us at {settings.PRETIX_EMAIL_NONE_VALUE}.\n\n" + f"Thank you for your attention and for choosing us!\n\n" + f"Warm regards,\n" + f"EventYay Team" + ) + billing_invoice_send_email( + mail_subject, mail_content, invoice, organizer_billing + ) + + invoice.last_reminder_datetime = reminder_date + invoice.next_reminder_datetime = get_next_reminder_datetime( + invoice.reminder_schedule + ) + invoice.save() + break + logger.info("End - completed task to check billing status for warning.") + + +def billing_invoice_send_email(subject, content, invoice, organizer_billing): + organizer_billing_contact = [organizer_billing.primary_contact_email] + # generate invoice pdf + pdf_buffer = generate_invoice_pdf(invoice, organizer_billing) + # Send email to organizer + pdf_content = pdf_buffer.getvalue() + pdf_base64 = base64.b64encode(pdf_content).decode("utf-8") + mail_send_task.apply_async( + kwargs={ + "subject": subject, + "body": content, + "sender": settings.PRETIX_EMAIL_NONE_VALUE, + "to": organizer_billing_contact, + "html": None, + "attach_file_base64": pdf_base64, + "attach_file_name": pdf_buffer.filename, + } + ) diff --git a/src/pretix/eventyay_common/views/billing.py b/src/pretix/eventyay_common/views/billing.py new file mode 100644 index 000000000..1e91037af --- /dev/null +++ b/src/pretix/eventyay_common/views/billing.py @@ -0,0 +1,28 @@ +from django.http import JsonResponse +from django.views import View + +from pretix.eventyay_common.tasks import ( + billing_invoice_notification, check_billing_status_for_warning, + monthly_billing_collect, process_auto_billing_charge, retry_failed_payment, +) + + +class BillingInvoicePreview(View): + + def get(self, request, *args, **kwargs): + """ + @summary: This view is using for trigger the billing invoice task testing only. Will be removed in production. + @return: json message + """ + if self.kwargs['task'] == 'invoice-collect': + monthly_billing_collect() + elif self.kwargs['task'] == 'invoice-notification': + billing_invoice_notification() + elif self.kwargs['task'] == 'invoice-charge': + process_auto_billing_charge() + elif self.kwargs['task'] == 'invoice-retry': + retry_failed_payment() + elif self.kwargs['task'] == 'invoice-warning': + check_billing_status_for_warning() + + return JsonResponse({'status': 'success', 'message': 'success.'}) diff --git a/src/pretix/helpers/stripe_utils.py b/src/pretix/helpers/stripe_utils.py index d1ab97b48..ee0f368f4 100644 --- a/src/pretix/helpers/stripe_utils.py +++ b/src/pretix/helpers/stripe_utils.py @@ -4,13 +4,27 @@ import stripe from django.core.exceptions import ValidationError -from pretix.base.models import Organizer +from pretix.base.models import BillingInvoice, Organizer from pretix.base.models.organizer import OrganizerBillingModel from pretix.base.settings import GlobalSettingsObject logger = logging.getLogger(__name__) +def get_stripe_webhook_secret_key() -> str: + """ + Retrieve the Stripe webhook secret key. + @return: A string representing the Stripe webhook secret key. + """ + gs = GlobalSettingsObject() + stripe_webhook_secret_key = getattr(gs.settings, "stripe_webhook_secret_key", None) + if not stripe_webhook_secret_key: + logger.error("Stripe webhook secret key not found") + raise ValidationError("Stripe webhook secret key not found.") + logger.info("Get successful Stripe webhook secret key") + return stripe_webhook_secret_key + + def get_stripe_key(key_type: str) -> str: """ Retrieve the Stripe key. @@ -20,19 +34,26 @@ def get_stripe_key(key_type: str) -> str: gs = GlobalSettingsObject() try: - prod_key = getattr(gs.settings, "payment_stripe_connect_{}_key".format(key_type), None) - test_key = getattr(gs.settings, "payment_stripe_connect_test_{}_key".format(key_type), None) + prod_key = getattr( + gs.settings, "payment_stripe_connect_{}_key".format(key_type), None + ) + test_key = getattr( + gs.settings, "payment_stripe_connect_test_{}_key".format(key_type), None + ) except AttributeError as e: logger.error("Missing attribute for Stripe %s key: %s", key_type, str(e)) raise ValidationError( "Missing attribute for Stripe {} key: {}. Please contact the administrator to set the Stripe key.".format( - key_type, str(e)), + key_type, str(e) + ), ) if not prod_key and not test_key: logger.error("No Stripe %s key found", key_type) raise ValidationError( - "Please contact the administrator to set the Stripe {} key.".format(key_type) + "Please contact the administrator to set the Stripe {} key.".format( + key_type + ) ) logger.info("Get successful %s key", key_type) @@ -54,6 +75,7 @@ def handle_stripe_errors(operation_name: str): @param operation_name: A string representing the operation name. @return: A decorator function. """ + def decorator(func): @wraps(func) def wrapper(*args, **kwargs): @@ -63,11 +85,17 @@ def wrapper(*args, **kwargs): logger.error("Stripe API error during %s: %s", operation_name, str(e)) raise ValidationError("Stripe service error.") except stripe.error.APIConnectionError as e: - logger.error("API connection error during %s: %s", operation_name, str(e)) + logger.error( + "API connection error during %s: %s", operation_name, str(e) + ) raise ValidationError("Network communication error.") except stripe.error.AuthenticationError as e: - logger.error("Authentication error during %s: %s", operation_name, str(e)) - raise ValidationError("Authentication failed. Please contact the administrator to check the configuration of the Stripe API key.") + logger.error( + "Authentication error during %s: %s", operation_name, str(e) + ) + raise ValidationError( + "Authentication failed. Please contact the administrator to check the configuration of the Stripe API key." + ) except stripe.error.CardError as e: logger.error("Card error during %s: %s", operation_name, str(e)) raise ValidationError("Card error.") @@ -75,10 +103,16 @@ def wrapper(*args, **kwargs): logger.error("Rate limit error during %s: %s", operation_name, str(e)) raise ValidationError("Too many requests. Please try again later.") except stripe.error.InvalidRequestError as e: - logger.error("Invalid request error during %s: %s", operation_name, str(e)) + logger.error( + "Invalid request error during %s: %s", operation_name, str(e) + ) raise ValidationError("Invalid request.") except stripe.error.SignatureVerificationError as e: - logger.error("Signature verification failed during %s: %s", operation_name, str(e)) + logger.error( + "Signature verification failed during %s: %s", + operation_name, + str(e), + ) raise ValidationError("Webhook signature verification failed.") except stripe.error.PermissionError as e: logger.error("Permission error during %s: %s", operation_name, str(e)) @@ -109,9 +143,9 @@ def create_setup_intent(customer_id: str) -> str: usage="off_session", ) logger.info("Created a successful setup intent.") - billing_settings_updated = OrganizerBillingModel.objects.filter(stripe_customer_id=customer_id).update( - stripe_setup_intent_id=stripe_setup_intent.id - ) + billing_settings_updated = OrganizerBillingModel.objects.filter( + stripe_customer_id=customer_id + ).update(stripe_setup_intent_id=stripe_setup_intent.id) if not billing_settings_updated: logger.error("No billing settings found for the customer %s", customer_id) raise ValidationError("No billing settings found for the customer.") @@ -173,9 +207,9 @@ def update_payment_info(setup_intent_id: str, customer_id: str): if not payment_method: logger.error("No payment method found for the setup intent %s", setup_intent_id) raise ValidationError("No payment method found for the setup intent.") - billing_setting_updated = OrganizerBillingModel.objects.filter(stripe_customer_id=customer_id).update( - stripe_payment_method_id=payment_method - ) + billing_setting_updated = OrganizerBillingModel.objects.filter( + stripe_customer_id=customer_id + ).update(stripe_payment_method_id=payment_method) if not billing_setting_updated: logger.error("No billing settings found for the customer %s", customer_id) raise ValidationError("No billing settings found for the customer.") @@ -251,3 +285,83 @@ def get_setup_intent(setup_intent_id: str): setup_intent = stripe.SetupIntent.retrieve(setup_intent_id) logger.info("Retrieve successful setup intent.") return setup_intent + + +@handle_stripe_errors("create_payment_intent") +def create_payment_intent( + amount: int, + currency: str, + customer_id: str, + payment_method_id: str, + metadata: dict, + invoice_id: str, +): + """ + Create a payment intent to process automatic billing charge. + @param amount: int representing the amount charged in cents. + @param currency: A string representing the currency in ISO code. + @param customer_id: A string representing the customer ID. + @param payment_method_id: A string representing the payment method ID. + @param metadata: A dictionary of key-value pairs that you can attach to a payment object. + @param invoice_id: A string representing the invoice ID. + @return: A dictionary containing the payment intent information. + """ + stripe.api_key = get_stripe_secret_key() + payment_intent = stripe.PaymentIntent.create( + amount=int(amount * 100), + currency=currency, + customer=customer_id, + payment_method=payment_method_id, + automatic_payment_methods={"enabled": True, "allow_redirects": "never"}, + metadata=metadata, + ) + billing_invoice_updated = BillingInvoice.objects.filter(id=invoice_id).update( + stripe_payment_intent_id=payment_intent.id + ) + if not billing_invoice_updated: + logger.error("No billing invoice found for the invoice %s", invoice_id) + raise ValidationError("No billing invoice found for the invoice.") + logger.info("Created a successful payment intent.") + return payment_intent + + +@handle_stripe_errors("confirm_payment_intent") +def confirm_payment_intent(payment_intent_id: str, payment_method_id: str): + """ + Confirm the payment intent to process automatic billing charge. + @param payment_intent_id: A string representing the payment intent ID. + @param payment_method_id: A string representing the payment method ID. + @return: A dictionary containing the payment intent confirmation information. + """ + stripe.api_key = get_stripe_secret_key() + payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id) + payment_intent.confirm(payment_method=payment_method_id) + logger.info("Confirmed successful payment intent.") + return payment_intent + + +def process_auto_billing_charge_stripe( + organizer_slug: str, amount: int, currency: str, metadata: dict, invoice_id: str +): + """ + Process the automatic billing charge using Stripe. + @param organizer_slug: A string representing the organizer slug. + @param amount: int representing the amount charged in cents. + @param currency: A string representing the currency in ISO code. + @param metadata: A dictionary of key-value pairs that you can attach to a payment object. + @param invoice_id: A string representing the invoice ID. + @return: A dictionary containing the payment intent confirmation information. + """ + stripe.api_key = get_stripe_secret_key() + customer_id = get_stripe_customer_id(organizer_slug) + payment_method = get_payment_method_info(customer_id) + if not payment_method: + logger.error("No payment method found for the customer %s", customer_id) + raise ValidationError("No payment method found for the customer.") + payment_intent = create_payment_intent( + amount, currency, customer_id, payment_method.id, metadata, invoice_id + ) + payment_intent_confirmation_info = confirm_payment_intent( + payment_intent.id, payment_method.id + ) + return payment_intent_confirmation_info diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 769fc5d73..9457993a4 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -6,6 +6,7 @@ import django.conf.locale import importlib_metadata +from celery.schedules import crontab from django.core.exceptions import ImproperlyConfigured from django.utils.crypto import get_random_string from kombu import Queue @@ -735,6 +736,31 @@ ('pretix.plugins.banktransfer.*', {'queue': 'background'}), ],) +CELERY_BEAT_SCHEDULE = { + "monthly_billing_collect": { + "task": "pretix.eventyay_common.tasks.monthly_billing_collect", + "schedule": crontab(day_of_month=1, hour=0, minute=0), # Run 1st every month at 00:00 + }, + "billing_invoice_notification": { + "task": "pretix.eventyay_common.tasks.billing_invoice_notification", + "schedule": crontab(day_of_month=1, hour=0, minute=10), # Run 1st every month at 00:10 + }, + "process_auto_billing_charge": { + "task": "pretix.eventyay_common.tasks.process_auto_billing_charge", + "schedule": crontab(day_of_month=1, hour=0, minute=20), # Run 1st every month at 00:20 + }, + "retry_failed_payment": { + "task": "pretix.eventyay_common.tasks.retry_failed_payment", + "schedule": crontab(hour=0, minute=30), # Run every day at 00:30 + }, + "check_billing_status_for_warning": { + "task": "pretix.eventyay_common.tasks.check_billing_status_for_warning", + "schedule": crontab(hour=0, minute=40), # Run every day at 00:40 + }, +} + +BILLING_REMINDER_SCHEDULE = [15, 29] # Remind on the 15th and 28th day of the month + BOOTSTRAP3 = { 'success_css_class': '', 'field_renderers': { diff --git a/src/pretix/static/pretixbase/img/eventyay-logo.png b/src/pretix/static/pretixbase/img/eventyay-logo.png new file mode 100644 index 000000000..4496c2bf1 Binary files /dev/null and b/src/pretix/static/pretixbase/img/eventyay-logo.png differ