diff --git a/fragdenstaat_de/fds_donation/admin.py b/fragdenstaat_de/fds_donation/admin.py index 5fa136cf9..91a1e54ac 100644 --- a/fragdenstaat_de/fds_donation/admin.py +++ b/fragdenstaat_de/fds_donation/admin.py @@ -70,6 +70,7 @@ DonationGift, DonationGiftOrder, Donor, + DonorEvent, DonorTag, Recurrence, ) @@ -1552,3 +1553,29 @@ def sum_amount(self, obj): def days(self, obj): return obj.days() + + +@admin.register(DonorEvent) +class DonorEventAdmin(admin.ModelAdmin): + readonly_fields = ( + "donor", + "timestamp", + "kind", + "reference", + "context", + ) + raw_id_fields = ("donor",) + list_display = ( + "donor", + "timestamp", + "kind", + "reference", + ) + date_hierarchy = "timestamp" + list_filter = ( + "kind", + ("donor", ForeignKeyFilter), + ) + + def get_queryset(self, request): + return super().get_queryset(request).select_related("donor") diff --git a/fragdenstaat_de/fds_donation/cms_plugins.py b/fragdenstaat_de/fds_donation/cms_plugins.py index 871a5ccde..d0a313a50 100644 --- a/fragdenstaat_de/fds_donation/cms_plugins.py +++ b/fragdenstaat_de/fds_donation/cms_plugins.py @@ -11,6 +11,7 @@ from fragdenstaat_de.fds_cms.utils import get_plugin_children from fragdenstaat_de.fds_mailing.cms_plugins import EmailRenderMixin, EmailTemplateMixin +from .forms import RecurrenceUpgradeForm from .models import ( DefaultDonation, DonationFormCMSPlugin, @@ -18,8 +19,10 @@ DonationGiftFormCMSPlugin, DonationProgressBarCMSPlugin, Donor, + DonorEvent, EmailDonationButtonCMSPlugin, RemoteDonationFormCMSPlugin, + UpgradeRecurrenceFormCMSPlugin, ) from .utils import get_donor_from_request @@ -52,6 +55,42 @@ def render(self, context, instance, placeholder): donor=context["donor"], category=instance.category, ) + return context + + +@plugin_pool.register_plugin +class UpgradeRecurrencePlugin(CMSPluginBase): + model = UpgradeRecurrenceFormCMSPlugin + module = _("Donations") + name = _("Upgrade recurrence") + text_enabled = True + cache = False + allow_children = True + render_template = "fds_donation/cms_plugins/upgrade_recurrence.html" + + def render(self, context, instance, placeholder): + context = super().render(context, instance, placeholder) + + context["donor"] = get_donor_from_request(context["request"]) + context["next_url"] = instance.next_url or "/" + + if context["donor"]: + DonorEvent.objects.create( + donor=context["donor"], + reference=context["request"].GET.get("pk_campaign", ""), + kind=DonorEvent.Kind.PROMPT_UPGRADE_RECURRENCE, + ) + active_recurrence = context["donor"].get_current_recurrence() + if active_recurrence and active_recurrence.should_upgrade( + instance.days_since + ): + context["form"] = RecurrenceUpgradeForm( + recurrence=active_recurrence, + choice_count=instance.choice_count, + initial={ + "reference": context["request"].GET.get("pk_campaign", "") + }, + ) return context diff --git a/fragdenstaat_de/fds_donation/export.py b/fragdenstaat_de/fds_donation/export.py index 2c8ded7ef..0d337e3fd 100644 --- a/fragdenstaat_de/fds_donation/export.py +++ b/fragdenstaat_de/fds_donation/export.py @@ -7,7 +7,6 @@ from django import forms from django.http import HttpResponse, StreamingHttpResponse -from django.template.defaultfilters import floatformat from django.utils import formats, timezone from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ @@ -18,13 +17,13 @@ from froide.helper.csv_utils import dict_to_csv_stream, export_csv_response from froide.helper.email_sending import mail_registry -from fragdenstaat_de.fds_donation.remote_filing import backup_donation_file -from fragdenstaat_de.fds_donation.tasks import ( +from .models import Donor +from .remote_filing import backup_donation_file +from .tasks import ( backup_jzwb_pdf_task, send_jzwb_mailing_task, ) - -from .models import Donor +from .utils import format_decimal_amount, format_decimal_amount_with_currency MAX_DONATIONS_PER_PAGE = 26 @@ -195,14 +194,6 @@ def send_mailing( } -def format_amount_with_currency(num: Decimal) -> str: - return "{} €".format(format_amount(num)) - - -def format_amount(num: Decimal) -> str: - return floatformat(num, "2g") - - def get_zwbs(donors, year: int): for donor in donors: data = get_zwb(donor, year) @@ -252,7 +243,7 @@ def get_zwb_data(donor: Donor, donation_data): "Land": donor.country.name, "Anrede": donor.get_salutation_display(), "Briefanrede": donor.get_german_salutation(), - "Jahressumme": format_amount(total_amount), + "Jahressumme": format_decimal_amount(total_amount), "JahressummeInWorten": amount_to_words(total_amount), "NutzerKonto": donor_account, "receipt_already": any(d["receipt_date"] for d in donation_data), @@ -301,7 +292,7 @@ def get_donation_data(donations, ignore_receipt_date: Optional[datetime] = None) return [ { "date": format_date(donation.received_timestamp), - "formatted_amount": format_amount_with_currency(donation.amount), + "formatted_amount": format_decimal_amount_with_currency(donation.amount), "receipt_date": ( donation.receipt_date < ignore_receipt_date if ignore_receipt_date and donation.receipt_date @@ -406,7 +397,7 @@ def send_jzwb_mailing( "donor": donor, "name": donor.get_full_name(), "salutation": donor.get_salutation(), - "total_amount": format_amount(total_amount), + "total_amount": format_decimal_amount(total_amount), } jzwb_mail = jzwb_mails[year] diff --git a/fragdenstaat_de/fds_donation/forms.py b/fragdenstaat_de/fds_donation/forms.py index 2b9ba8751..afa78b1c9 100644 --- a/fragdenstaat_de/fds_donation/forms.py +++ b/fragdenstaat_de/fds_donation/forms.py @@ -7,6 +7,7 @@ from django.conf import settings from django.contrib.admin.widgets import ForeignKeyRawIdWidget from django.core.validators import MinValueValidator +from django.utils import timezone from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ @@ -44,7 +45,13 @@ Recurrence, ) from .services import get_or_create_donor, send_donor_login_link, send_email_change_link -from .utils import MERGE_DONOR_FIELDS +from .utils import ( + MERGE_DONOR_FIELDS, + format_amount_with_currency, + get_next_min_amount, + get_upgrade_amounts, + make_presets, +) from .validators import validate_not_too_many_uppercase from .widgets import AmountInput @@ -69,6 +76,7 @@ class BasicDonationForm(StartPaymentMixin, forms.Form): "class": "text-end", }, presets=[], + min_value=MIN_AMOUNT, ), ) interval = forms.TypedChoiceField( @@ -150,7 +158,9 @@ def __init__(self, *args, **kwargs): # No once option -> not choosing purpose self.fields["purpose"].widget = forms.HiddenInput() - self.fields["amount"].widget.presets = self.settings["amount_presets"] + self.fields["amount"].widget.presets = make_presets( + self.settings["amount_presets"] + ) self.fields["reference"].initial = self.settings["reference"] self.fields["keyword"].initial = self.settings["keyword"] if self.settings["purpose"]: @@ -321,6 +331,7 @@ class RemoteDonationForm(forms.Form): "class": "text-end", }, presets=[], + min_value=MIN_AMOUNT, ), ) initial_interval = forms.TypedChoiceField( @@ -343,7 +354,9 @@ def __init__(self, *args, **kwargs): self.fields["initial_interval"].initial = ( self.settings.get("initial_interval", None) or interval_choices[0][0] ) - self.fields["initial_amount"].widget.presets = self.settings["amount_presets"] + self.fields["initial_amount"].widget.presets = make_presets( + self.settings["amount_presets"] + ) self.fields["initial_amount"].initial = self.settings["initial_amount"] self.fields["pk_campaign"].initial = self.settings["reference"] self.fields["pk_keyword"].initial = self.settings["keyword"] @@ -773,6 +786,8 @@ def __init__(self, *args, **kwargs): if self.gift_options: self.fields["chosen_gift"].queryset = self.gift_options self.fields["chosen_gift"].initial = self.gift_options[0].id + if len(self.gift_options) == 1: + self.fields["chosen_gift"].widget = forms.HiddenInput() self.fields["chosen_gift"].widget.attrs["data-needs-address"] = json.dumps( {gift.id: gift.needs_address for gift in self.gift_options} ) @@ -803,6 +818,13 @@ def clean(self): _("No donation gifts are available for you at the moment.") ) DonationGiftLogic.clean(self) + self.related_donation = ( + self.donor.donations.all().filter(completed=True).latest("timestamp") + ) + if DonationGiftOrder.objects.filter(donation=self.related_donation).exists(): + raise forms.ValidationError( + _("You have already ordered this donation gift.") + ) def save(self): if self.cleaned_data["update_address"]: @@ -811,10 +833,9 @@ def save(self): self.donor.city = self.cleaned_data["shipping_city"] self.donor.country = self.cleaned_data["shipping_country"] self.donor.save() + order = DonationGiftOrder.objects.create( - donation=self.donor.donations.all() - .filter(completed=True) - .latest("timestamp"), + donation=self.related_donation, donation_gift=self.cleaned_data["chosen_gift"], first_name=self.cleaned_data["shipping_first_name"], last_name=self.cleaned_data["shipping_last_name"], @@ -946,3 +967,79 @@ def send_login_link(self): except Donor.DoesNotExist: donor = None send_donor_login_link(donor, email, next_path=next_path) + + +class RecurrenceUpgradeForm(forms.Form): + reference = forms.CharField(widget=forms.HiddenInput, required=False) + + def __init__(self, recurrence=None, choice_count=3, *args, **kwargs): + self.recurrence: Recurrence | None = recurrence + super().__init__(*args, **kwargs) + self.fields["recurrence"] = forms.ModelChoiceField( + Recurrence.objects.all(), + initial=recurrence, + widget=forms.HiddenInput, + required=True, + ) + amounts = get_upgrade_amounts( + recurrence.amount, recurrence.interval, choice_count=choice_count + ) + new_min_amount = get_next_min_amount(recurrence.amount) + choices = [ + ( + amount, + format_amount_with_currency(amount), + ) + for amount in amounts + ] + if recurrence.interval == 1: + label = _("Your new monthly amount:") + elif recurrence.interval == 12: + label = _("Your new yearly amount:") + elif recurrence.interval == 3: + label = _("Your new quarterly amount:") + else: + label = _("Your new amount every {} months:").format(recurrence.interval) + self.fields["upgrade_amount"] = forms.DecimalField( + localize=True, + required=True, + initial=None, + min_value=new_min_amount, + max_digits=19, + decimal_places=2, + label=label, + widget=AmountInput( + attrs={ + "title": _("Amount in Euro, comma as decimal separator"), + "class": "text-end", + }, + presets=choices, + min_value=new_min_amount, + ), + ) + + def can_upgrade(self): + subscription = self.recurrence.subscription + if not subscription: + return False + modify_info = subscription.get_modify_info() + return modify_info.can_modify + + def save(self): + amount = self.cleaned_data["upgrade_amount"] + if not amount: + return + if self.can_upgrade(): + subscription = self.recurrence.subscription + return self.modify_subscription(subscription) + + def modify_subscription(self, subscription): + provider = subscription.get_provider() + result = provider.modify_subscription( + subscription, + amount=self.cleaned_data["upgrade_amount"], + interval=subscription.plan.interval, + ) + if result: + self.recurrence.update_from_subscription(last_upgrade=timezone.now()) + return result diff --git a/fragdenstaat_de/fds_donation/listeners.py b/fragdenstaat_de/fds_donation/listeners.py index 506ce4938..4bde0c181 100644 --- a/fragdenstaat_de/fds_donation/listeners.py +++ b/fragdenstaat_de/fds_donation/listeners.py @@ -97,8 +97,10 @@ def process_new_donation(donation, received_now=False, domain_obj=None): def subscription_was_canceled(sender, **kwargs): if sender is None: return - Recurrence.objects.filter(subscription=sender).update(cancel_date=sender.canceled) + recurrence = Recurrence.objects.filter(subscription=sender).first() + if recurrence: + detect_recurring_on_donor(recurrence.donor) def user_email_changed(sender, old_email=None, **kwargs): diff --git a/fragdenstaat_de/fds_donation/migrations/0071_upgraderecurrenceformcmsplugin.py b/fragdenstaat_de/fds_donation/migrations/0071_upgraderecurrenceformcmsplugin.py new file mode 100644 index 000000000..5fe7e0de4 --- /dev/null +++ b/fragdenstaat_de/fds_donation/migrations/0071_upgraderecurrenceformcmsplugin.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.10 on 2026-04-16 14:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('cms', '0041_alter_pageurl_unique_together_pageurl_site_and_more'), + ('fds_donation', '0070_donor_recurrence_streak_start'), + ] + + operations = [ + migrations.CreateModel( + name='UpgradeRecurrenceFormCMSPlugin', + fields=[ + ('cmsplugin_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='%(app_label)s_%(class)s', serialize=False, to='cms.cmsplugin')), + ('next_url', models.CharField(blank=True, max_length=255)), + ], + bases=('cms.cmsplugin',), + ), + ] diff --git a/fragdenstaat_de/fds_donation/migrations/0072_upgraderecurrenceformcmsplugin_choice_count.py b/fragdenstaat_de/fds_donation/migrations/0072_upgraderecurrenceformcmsplugin_choice_count.py new file mode 100644 index 000000000..b01444b36 --- /dev/null +++ b/fragdenstaat_de/fds_donation/migrations/0072_upgraderecurrenceformcmsplugin_choice_count.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.10 on 2026-04-23 09:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fds_donation', '0071_upgraderecurrenceformcmsplugin'), + ] + + operations = [ + migrations.AddField( + model_name='upgraderecurrenceformcmsplugin', + name='choice_count', + field=models.SmallIntegerField(default=3), + ), + ] diff --git a/fragdenstaat_de/fds_donation/migrations/0073_donorevent.py b/fragdenstaat_de/fds_donation/migrations/0073_donorevent.py new file mode 100644 index 000000000..09257d256 --- /dev/null +++ b/fragdenstaat_de/fds_donation/migrations/0073_donorevent.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.10 on 2026-04-28 09:48 + +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fds_donation', '0072_upgraderecurrenceformcmsplugin_choice_count'), + ] + + operations = [ + migrations.CreateModel( + name='DonorEvent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('timestamp', models.DateTimeField(default=django.utils.timezone.now)), + ('kind', models.CharField(choices=[('prompt_upgrade_recurrence', 'prompt recurrence upgrade'), ('upgrade_recurrence', 'upgraded recurrence')], max_length=255)), + ('reference', models.CharField(blank=True, max_length=255)), + ('context', models.JSONField(default=dict)), + ('donor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='fds_donation.donor')), + ], + options={ + 'verbose_name': 'donor event', + 'verbose_name_plural': 'donor events', + }, + ), + ] diff --git a/fragdenstaat_de/fds_donation/migrations/0074_recurrence_last_upgrade_and_more.py b/fragdenstaat_de/fds_donation/migrations/0074_recurrence_last_upgrade_and_more.py new file mode 100644 index 000000000..adb11d29d --- /dev/null +++ b/fragdenstaat_de/fds_donation/migrations/0074_recurrence_last_upgrade_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.10 on 2026-04-28 11:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fds_donation', '0073_donorevent'), + ] + + operations = [ + migrations.AddField( + model_name='recurrence', + name='last_upgrade', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='upgraderecurrenceformcmsplugin', + name='days_since', + field=models.IntegerField(default=0), + ), + ] diff --git a/fragdenstaat_de/fds_donation/models.py b/fragdenstaat_de/fds_donation/models.py index 2d00f41c0..93c0ee4e0 100644 --- a/fragdenstaat_de/fds_donation/models.py +++ b/fragdenstaat_de/fds_donation/models.py @@ -296,6 +296,9 @@ def calculate_recurring_amount(self): total=models.Sum(models.F("amount") / models.F("interval")) )["total"] or decimal.Decimal("0.00") + def get_current_recurrence(self): + return self.recurrences.filter(cancel_date=None).order_by("-start_date").first() + def get_recurrence_streak_start_date(self): """ Returns the start date of the current recurrence streak. @@ -467,6 +470,7 @@ class Recurrence(models.Model): amount = models.DecimalField( max_digits=12, decimal_places=settings.DEFAULT_DECIMAL_PLACES, default=0 ) + last_upgrade = models.DateTimeField(null=True, blank=True) cancel_date = models.DateTimeField(null=True, blank=True) cancel_reason = models.CharField( max_length=255, @@ -486,6 +490,10 @@ class Meta: def __str__(self): return "{donor}: {desc}".format(donor=self.donor, desc=self.get_description()) + @property + def amount_per_month(self): + return self.amount / self.interval + def get_description(self): return ngettext_lazy( "{amount} EUR every month via {method} since {start}.", @@ -531,6 +539,8 @@ def sum_amount(self): )["total_amount"] or decimal.Decimal("0.00") def days(self): + if self.start_date is None: + return 0 last_date = timezone.now() if self.cancel_date: last_date = self.cancel_date @@ -572,6 +582,22 @@ def last_donation(self): """ return self.donations.order_by("timestamp").last() + def update_from_subscription(self, last_upgrade=None): + if not self.subscription: + return + if last_upgrade: + self.last_upgrade = last_upgrade + self.interval = self.subscription.plan.interval + self.amount = self.subscription.plan.amount + self.save(update_fields=["interval", "amount", "last_upgrade"]) + + def should_upgrade(self, days_between_upgrade): + if not days_between_upgrade: + return True + last_upgrade = self.last_upgrade or self.start_date + days_since = (timezone.now() - last_upgrade).days + return days_between_upgrade < days_since + class DonationManager(models.Manager): def estimate_received_donations(self, start_date: date): @@ -622,6 +648,28 @@ def estimate_received_donations(self, start_date: date): ) +class DonorEvent(models.Model): + class Kind(models.TextChoices): + PROMPT_UPGRADE_RECURRENCE = ( + "prompt_upgrade_recurrence", + _("prompt recurrence upgrade"), + ) + UPGRADE_RECURRENCE = "upgrade_recurrence", _("upgraded recurrence") + + donor = models.ForeignKey(Donor, on_delete=models.CASCADE, related_name="events") + timestamp = models.DateTimeField(default=timezone.now) + kind = models.CharField(max_length=255, choices=Kind.choices) + reference = models.CharField(max_length=255, blank=True) + context = models.JSONField(default=dict) + + class Meta: + verbose_name = _("donor event") + verbose_name_plural = _("donor events") + + def __str__(self): + return f"{self.donor} - {self.get_kind_display()} at {self.timestamp}" + + class Donation(models.Model): donor = models.ForeignKey( Donor, @@ -1221,3 +1269,12 @@ def make_form(self, **kwargs): plugin_data.update(request_data) return RemoteDonationForm(form_settings=plugin_data) + + +class UpgradeRecurrenceFormCMSPlugin(CMSPlugin): + choice_count = models.SmallIntegerField(default=3) + days_since = models.IntegerField(default=0) + next_url = models.CharField(max_length=255, blank=True) + + def __str__(self): + return _("Upgrade Recurrence ({})").format(self.choice_count) diff --git a/fragdenstaat_de/fds_donation/templates/fds_donation/cms_plugins/upgrade_recurrence.html b/fragdenstaat_de/fds_donation/templates/fds_donation/cms_plugins/upgrade_recurrence.html new file mode 100644 index 000000000..dde8718d7 --- /dev/null +++ b/fragdenstaat_de/fds_donation/templates/fds_donation/cms_plugins/upgrade_recurrence.html @@ -0,0 +1,32 @@ +{% load i18n %} +{% load cms_tags %} +{% load form_helper %} +{% if form.can_upgrade %} +
+ {% for plugin in instance.child_plugin_instances %} + {% render_plugin plugin %} + {% endfor %} +
+ {% csrf_token %} + + {{ form.reference }} + {{ form.recurrence }} + +
+
{{ form.upgrade_amount }}
+
+ +
+
+
+
+{% elif request.toolbar.edit_mode_active %} +
+ {% for plugin in instance.child_plugin_instances %} + {% render_plugin plugin %} + {% endfor %} +

{{ instance }}

+
+{% endif %} diff --git a/fragdenstaat_de/fds_donation/templates/fds_donation/donation_form.html b/fragdenstaat_de/fds_donation/templates/fds_donation/donation_form.html index 82360d410..409bd5b5c 100644 --- a/fragdenstaat_de/fds_donation/templates/fds_donation/donation_form.html +++ b/fragdenstaat_de/fds_donation/templates/fds_donation/donation_form.html @@ -5,7 +5,7 @@ {% endblock %} {% block app_body %}
-
+
{% if has_donation %}

{% translate "Create a new donation" %}

{% else %} @@ -77,11 +77,13 @@

{% translate "Donate now" %}

{% endif %} {% endfor %} - - {% if has_donation %} - {% translate "Cancel" %} - {% endif %} +

+ {% if has_donation %} + {% translate "Cancel" %} + {% endif %} + +

diff --git a/fragdenstaat_de/fds_donation/templates/fds_donation/forms/widgets/amount_input.html b/fragdenstaat_de/fds_donation/templates/fds_donation/forms/widgets/amount_input.html index e2224f9de..8965e4e60 100644 --- a/fragdenstaat_de/fds_donation/templates/fds_donation/forms/widgets/amount_input.html +++ b/fragdenstaat_de/fds_donation/templates/fds_donation/forms/widgets/amount_input.html @@ -1,20 +1,22 @@ {% load i18n humanize %}
{% if presets %} -
- {% for preset in presets %} - +