From f7421b82a29b993941749a086854aa251e1f63ff Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Fri, 31 Jul 2026 19:48:25 +0100 Subject: [PATCH 1/5] Add a task for refunding/partrefunding keebspansions This ended up a bit more complicated to deal with edgecases that I'm not convinced exist in practice but may be useful to handle in future years: - Payments for a single, 'part-collected' keebspansion -> payment marked part-refunded, purchase changes price/price tier/product - Payments for a single, uncollected keebspansion -> payment marked refunded, purchase marked refunded - Payments that include those things but also other stuff -> payment marked part-refunded, purchase handled as above - Payments that include a combination of part-collected and uncollected keebspansions -> payment marked part-refunded, purchases handled as above - Users with keebspansion payments in multiple currencies, with some combination of the above -> does mostly the right thing as above --- apps/tickets/tasks.py | 193 ++++++++++++++++++- models/product.py | 2 +- templates/emails/emf2026-keebdeck-refund.txt | 28 +++ 3 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 templates/emails/emf2026-keebdeck-refund.txt diff --git a/apps/tickets/tasks.py b/apps/tickets/tasks.py index dfd4d630e..b8b4fd635 100644 --- a/apps/tickets/tasks.py +++ b/apps/tickets/tasks.py @@ -1,4 +1,6 @@ -from datetime import timedelta +from collections import defaultdict +from datetime import date, timedelta +from decimal import Decimal import click import googleapiclient.errors @@ -9,9 +11,10 @@ from apps.common import feature_enabled, walletpass from apps.common.receipt import RECEIPT_TYPES, attach_tickets, set_tickets_emailed +from apps.payments.refund import create_stripe_refund from main import db -from models import naive_utcnow -from models.payment import Payment +from models import Currency, naive_utcnow +from models.payment import Payment, StripePayment from models.product import ( Price, PriceTier, @@ -483,6 +486,190 @@ def email_tickets(user_id: int | None) -> None: walletpass.update_gwallet_pass_if_needed(user) +@tickets.cli.command("emf2026_refund_keebdecks") +@click.option( + "--dry-run", is_flag=True, help="If set, don't actually refund/change the database, just simulate it." +) +def emf2026_refund_keebdecks(dry_run: bool = True) -> None: + """Perform refunds/part refunds for keebdecks. + + For uncollected keebdecks we provide a full refund. + + For 'part collected' keebdecks (those signed out on Sunday by + badge@emfcamp.org), we downgrade them to the keebdeck-without-keyboard + product and provide a part refund. + + We also send appropriate emails to people to let them know what's happened. + """ + dry_run_prefix = "[DRYRUN] " if dry_run else "" + + ctx = app.test_request_context() + ctx.push() + + keebdeck_product_name = "badge-keebdeck" + keebless_name = "badge-keebdeck-without-keyboard" + keebless = db.session.execute(select(Product).where(Product.name == keebless_name)).scalar_one_or_none() + assert keebless is not None + keebless_price: dict[Currency, Price] = {} + for currency in Currency: + price = keebless.get_cheapest_price(currency) + assert price + keebless_price[currency] = price + + query = ( + select(User) + .join(User.owned_purchases) + .where( + Purchase.is_paid_for == True, + Purchase.product.has(Product.name == keebdeck_product_name), + ) + .group_by(User.id) + .order_by(User.id) + ) + + users = list(db.session.execute(query).unique().scalars()) + + def _redeemed_by_badge_on_sunday(purchase: Purchase) -> bool: + """https://chat.orga.emfcamp.org/emf/pl/wamc3u9kgjfd7nre78w3od98nw""" + if not purchase.redeemed: + return False + redemption_version = purchase.redemption_version() + assert redemption_version + redeemer_user: User = redemption_version.transaction.user + if redeemer_user.email != "badge@emfcamp.org": + return False + issued_at_date: date = redemption_version.transaction.issued_at.date() + return date(2026, 7, 19) == issued_at_date + + for user in users: + keebdeck_purchases = [ + purchase + for purchase in user.owned_purchases + if purchase.is_paid_for and purchase.product.name == keebdeck_product_name + ] + part_redeemed_purchases = [ + purchase + for purchase in keebdeck_purchases + if purchase.redeemed and _redeemed_by_badge_on_sunday(purchase) + ] + uncollected_purchases = [purchase for purchase in keebdeck_purchases if not purchase.redeemed] + refund_count = len(part_redeemed_purchases) + len(uncollected_purchases) + if refund_count == 0: + continue + + if len(uncollected_purchases) == len(keebdeck_purchases): + refund_type = "full" + elif len(part_redeemed_purchases) == len(keebdeck_purchases): + refund_type = "part" + else: + refund_type = "complex" + + total_amounts_by_currency: dict[Currency, Decimal] = defaultdict(Decimal) + + app.logger.info( + "%sRefunding %s for %d keebdecks (%d part-redeemed, %d uncollected)", + dry_run_prefix, + user.email, + refund_count, + len(part_redeemed_purchases), + len(uncollected_purchases), + ) + + # Group everything together by payment. + purchases_by_payment_id: dict[int, tuple[list[Purchase], list[Purchase]]] = defaultdict( + lambda: ([], []) + ) + for purchase in part_redeemed_purchases: + assert purchase.payment_id is not None + purchases_by_payment_id[purchase.payment_id][0].append(purchase) + for purchase in uncollected_purchases: + assert purchase.payment_id is not None + purchases_by_payment_id[purchase.payment_id][1].append(purchase) + for payment_id, (part_redeemed_in_payment, uncollected_in_payment) in purchases_by_payment_id.items(): + payment = db.session.execute(select(Payment).where(Payment.id == payment_id)).scalar_one_or_none() + assert payment + if not isinstance(payment, StripePayment): + app.logger.info( + "%sCannot refund %s (payment ID %d) -- not a Stripe payment", + dry_run_prefix, + user.email, + payment_id, + ) + continue + payment.lock() + if not payment.is_refundable(ignore_event_refund_state=True): + app.logger.info( + "%sCannot refund %s (payment ID %d) -- payment state is %s", + dry_run_prefix, + user.email, + payment_id, + payment.state, + ) + continue + payment_refund_amount = Decimal(0) + for purchase in part_redeemed_in_payment: + assert purchase.price.currency == payment.currency + payment_refund_amount += purchase.price.value - keebless_price[payment.currency] + new_price = keebless_price[payment.currency] + purchase.price = new_price + purchase.price_tier = new_price.price_tier + purchase.product = keebless + for purchase in uncollected_in_payment: + assert purchase.price.currency == payment.currency + payment_refund_amount += purchase.price.value + purchase.set_state("refunded") + total_amounts_by_currency[payment.currency] += payment_refund_amount + app.logger.info( + "%sRefunding %s (payment ID %d) for %d part-redeemed, %d uncollected keebdecks -- refunding %s %s", + dry_run_prefix, + user.email, + payment_id, + len(part_redeemed_purchases), + len(uncollected_purchases), + payment_refund_amount, + payment.currency, + ) + if not dry_run: + refund = create_stripe_refund( + payment, + payment_refund_amount, + { + "type": "keebdeck-refund", + "part-redeemed": ",".join(str(p.id) for p in part_redeemed_in_payment), + "uncollected": ",".join(str(p.id) for p in uncollected_in_payment), + }, + ) + db.session.add(refund) + payment.state = "refunded" if payment_refund_amount == payment.amount else "partrefunded" + + if dry_run: + db.session.rollback() + else: + db.session.commit() + + msg = EmailMessage( + "Your Electromagnetic Field Keyboard Hexpansion Refund", + from_email=config.from_email("TICKETS_EMAIL"), + to=[user.email], + ) + + refund_total_formatted = " and ".join( + f"{currency.symbol}{price:.2f}" for currency, price in total_amounts_by_currency.items() + ) + assert refund_total_formatted + msg.body = render_template( + "emails/emf2026-keebdeck-refund.txt", + user=user, + refund_type=refund_type, + refund_count=refund_count, + refund_total=refund_total_formatted, + ) + + app.logger.info("%sEmailing %s keebdeck refund notification", dry_run_prefix, user.email) + if not dry_run: + msg.send() + + @tickets.cli.group() def googlewallet(): pass diff --git a/models/product.py b/models/product.py index 914fe7c8d..97cd10ad0 100644 --- a/models/product.py +++ b/models/product.py @@ -515,7 +515,7 @@ class Price(BaseModel): price_int: Mapped[int] purchases: Mapped[list[Purchase]] = relationship(back_populates="price") - price_tier: Mapped[list[PriceTier]] = relationship(back_populates="prices") + price_tier: Mapped[PriceTier] = relationship(back_populates="prices") def __init__(self, currency, value=None, **kwargs): super().__init__(currency=currency.upper(), **kwargs) diff --git a/templates/emails/emf2026-keebdeck-refund.txt b/templates/emails/emf2026-keebdeck-refund.txt new file mode 100644 index 000000000..ef4e9a331 --- /dev/null +++ b/templates/emails/emf2026-keebdeck-refund.txt @@ -0,0 +1,28 @@ +{% extends "emails/base.txt" %} +{% block body %} +Hi {{ user.name }}, + +{% if refund_type == "complex" %} +You have been given a refund totalling {{ refund_total }} for your +keyboard hexpansion purchases. If you collected a keyboard hexpansion without the keyboard and dome +sheet, then you can buy it from solderparty @ https://www.lectronz.com/products/keebdeck-keyboard. +{% elif refund_type == "full" %} +Your keyboard hexpansion purchase{% if refund_count > 1 %}s have{% else %} has{% endif %} been fully refunded for {{ refund_total }}, +since {% if refund_count > 1 %}they were{% else %}it was{% endif %} uncollected. +{% else %} +Your keyboard hexpansion purchase{% if refund_count > 1 %}s have{% else %} has{% endif %} been partly refunded by {{ refund_total }}, +since you were given {% if refund_count == 1 %}an {% endif %}incomplete hexpansion{% if refund_count > 1 %}s{% endif %}. + +You can buy the silicone keyboard and dome sheet from solderparty @ https://www.lectronz.com/products/keebdeck-keyboard to complete your purchase. +{% endif %} + +{% if is_stripe %} +This has been refunded via stripe to the original form of payment. The money should be with you shortly. +{% endif %} + +You can confirm the status of your account by visiting {{ url_for('users.purchases', _external=True) }} + +Love, + +All the EMF team +{% endblock %} From 1bb99c6e6176d039a4db4a64e98f1f205c3059f6 Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Sat, 8 Aug 2026 21:44:31 +0100 Subject: [PATCH 2/5] Tweak the script to cope better with owner != purchaser --- apps/tickets/tasks.py | 64 +++++++++++++++++-- .../account/purchases/other-items-list.html | 2 +- templates/emails/emf2026-keebdeck-refund.txt | 33 ++++++---- 3 files changed, 82 insertions(+), 17 deletions(-) diff --git a/apps/tickets/tasks.py b/apps/tickets/tasks.py index b8b4fd635..56c217072 100644 --- a/apps/tickets/tasks.py +++ b/apps/tickets/tasks.py @@ -1,4 +1,4 @@ -from collections import defaultdict +from collections import Counter, defaultdict from datetime import date, timedelta from decimal import Decimal @@ -486,6 +486,51 @@ def email_tickets(user_id: int | None) -> None: walletpass.update_gwallet_pass_if_needed(user) +@tickets.cli.command("emf2026_refund_keebdecks_emailtest") +def emf2026_refund_keebdecks_emailtest() -> None: + """Renders the refund email in a variety of different ways.""" + ctx = app.test_request_context() + ctx.push() + + test_user = db.session.execute(select(User)).unique().scalar() + assert test_user + test_user.name = "John Appleseed" + + gbp = { + Currency.GBP: Decimal("12.34"), + } + eur = { + Currency.EUR: Decimal("12.34"), + } + gbp_eur = { + Currency.GBP: Decimal("12.34"), + Currency.EUR: Decimal("3.00"), + } + + for purchaser_type in ["owner", "other", "complex"]: + for refund_type in ["full", "part", "complex"]: + for refund_count in [1, 100]: + for total_amounts_by_currency in [gbp, eur, gbp_eur]: + refund_total_formatted = " and ".join( + f"{currency.symbol}{price:.2f}" + for currency, price in total_amounts_by_currency.items() + ) + app.logger.info( + f"{purchaser_type=} {refund_type=} {refund_count=} {total_amounts_by_currency=}" + ) + app.logger.info( + render_template( + "emails/emf2026-keebdeck-refund.txt", + user=test_user, + purchaser_type=purchaser_type, + refund_type=refund_type, + refund_count=refund_count, + refund_total=refund_total_formatted, + is_stripe=True, + ) + ) + + @tickets.cli.command("emf2026_refund_keebdecks") @click.option( "--dry-run", is_flag=True, help="If set, don't actually refund/change the database, just simulate it." @@ -557,6 +602,15 @@ def _redeemed_by_badge_on_sunday(purchase: Purchase) -> bool: if refund_count == 0: continue + purchaser_types = [ + "owner" if purchase.purchaser == user else "other" + for purchase in part_redeemed_purchases + uncollected_purchases + ] + purchaser_counter = Counter(purchaser_types) + purchaser_type, common = purchaser_counter.most_common(1)[0] + if common != purchaser_counter.total(): + purchaser_type = "complex" + if len(uncollected_purchases) == len(keebdeck_purchases): refund_type = "full" elif len(part_redeemed_purchases) == len(keebdeck_purchases): @@ -609,7 +663,7 @@ def _redeemed_by_badge_on_sunday(purchase: Purchase) -> bool: payment_refund_amount = Decimal(0) for purchase in part_redeemed_in_payment: assert purchase.price.currency == payment.currency - payment_refund_amount += purchase.price.value - keebless_price[payment.currency] + payment_refund_amount += purchase.price.value - keebless_price[payment.currency].value new_price = keebless_price[payment.currency] purchase.price = new_price purchase.price_tier = new_price.price_tier @@ -624,8 +678,8 @@ def _redeemed_by_badge_on_sunday(purchase: Purchase) -> bool: dry_run_prefix, user.email, payment_id, - len(part_redeemed_purchases), - len(uncollected_purchases), + len(part_redeemed_in_payment), + len(uncollected_in_payment), payment_refund_amount, payment.currency, ) @@ -660,9 +714,11 @@ def _redeemed_by_badge_on_sunday(purchase: Purchase) -> bool: msg.body = render_template( "emails/emf2026-keebdeck-refund.txt", user=user, + purchaser_type=purchaser_type, refund_type=refund_type, refund_count=refund_count, refund_total=refund_total_formatted, + is_stripe=True, # this year, it was all stripe ) app.logger.info("%sEmailing %s keebdeck refund notification", dry_run_prefix, user.email) diff --git a/templates/account/purchases/other-items-list.html b/templates/account/purchases/other-items-list.html index 7092340d9..d73e422c8 100644 --- a/templates/account/purchases/other-items-list.html +++ b/templates/account/purchases/other-items-list.html @@ -22,7 +22,7 @@ {%- if item.is_paid_for %} Paid {%- elif item.state == 'refunded' %} - Refunded + Refunded{% if item.purchaser != current_user %} to original purchaser{% endif %} {%- elif item.payment and item.payment.expired %} Unpaid {%- else %} diff --git a/templates/emails/emf2026-keebdeck-refund.txt b/templates/emails/emf2026-keebdeck-refund.txt index ef4e9a331..a7a86f035 100644 --- a/templates/emails/emf2026-keebdeck-refund.txt +++ b/templates/emails/emf2026-keebdeck-refund.txt @@ -2,23 +2,32 @@ {% block body %} Hi {{ user.name }}, -{% if refund_type == "complex" %} -You have been given a refund totalling {{ refund_total }} for your +{% if refund_type == "complex" -%} +{% if purchaser_type == "owner" %}You have{% else %}The original purchaser has{% endif %} been given a refund totalling {{ refund_total }} for your keyboard hexpansion purchases. If you collected a keyboard hexpansion without the keyboard and dome sheet, then you can buy it from solderparty @ https://www.lectronz.com/products/keebdeck-keyboard. -{% elif refund_type == "full" %} -Your keyboard hexpansion purchase{% if refund_count > 1 %}s have{% else %} has{% endif %} been fully refunded for {{ refund_total }}, -since {% if refund_count > 1 %}they were{% else %}it was{% endif %} uncollected. -{% else %} +{%- elif refund_type == "full" -%} +Your keyboard hexpansion purchase{% if refund_count > 1 %}s have{% else %} has{% endif %} been fully refunded for {{ refund_total }} +{% if purchaser_type != "owner" %}to the original purchaser {% endif %}since {% if refund_count > 1 %}they were{% else %}it was{% endif %} uncollected. +{%- else -%} Your keyboard hexpansion purchase{% if refund_count > 1 %}s have{% else %} has{% endif %} been partly refunded by {{ refund_total }}, -since you were given {% if refund_count == 1 %}an {% endif %}incomplete hexpansion{% if refund_count > 1 %}s{% endif %}. +{% if purchaser_type != "owner" %}to the original purchaser {% endif %}since you were given {% if refund_count == 1 %}an {% endif %}incomplete hexpansion{% if refund_count > 1 %}s{% endif %}. -You can buy the silicone keyboard and dome sheet from solderparty @ https://www.lectronz.com/products/keebdeck-keyboard to complete your purchase. -{% endif %} +You can buy the silicone keyboard and dome sheet from +solderparty @ https://www.lectronz.com/products/keebdeck-keyboard +to complete your purchase. +{%- endif %} -{% if is_stripe %} -This has been refunded via stripe to the original form of payment. The money should be with you shortly. -{% endif %} +{% if is_stripe -%} +This has been refunded via stripe to the original form of payment. +{% if purchaser_type == "owner" -%} +The money should be with you shortly. +{%- else -%} +Since {% if purchaser_type == "complex" %}some of {% endif %}these purchases were transferred to this account, +you may need to seek a refund from the original purchaser once they've +received the refund from us. +{%- endif %} +{%- endif %} You can confirm the status of your account by visiting {{ url_for('users.purchases', _external=True) }} From e367b857562b76ac06cca45297288de1709ec2e8 Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Sat, 8 Aug 2026 22:48:05 +0100 Subject: [PATCH 3/5] Mark items on account page as redeemed/collected if they were redeemed --- templates/account/purchases/other-items-list.html | 4 +++- templates/account/purchases/ticket-list.html | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/templates/account/purchases/other-items-list.html b/templates/account/purchases/other-items-list.html index d73e422c8..697311c77 100644 --- a/templates/account/purchases/other-items-list.html +++ b/templates/account/purchases/other-items-list.html @@ -19,7 +19,9 @@ {{ item.price_tier.parent.display_name }} {{ item.price.value | price(item.price.currency) }} - {%- if item.is_paid_for %} + {%- if item.redeemed %} + Collected + {%- elif item.is_paid_for %} Paid {%- elif item.state == 'refunded' %} Refunded{% if item.purchaser != current_user %} to original purchaser{% endif %} diff --git a/templates/account/purchases/ticket-list.html b/templates/account/purchases/ticket-list.html index a9a281495..02567b6d3 100644 --- a/templates/account/purchases/ticket-list.html +++ b/templates/account/purchases/ticket-list.html @@ -34,7 +34,9 @@ {{ t.price_tier.parent.display_name }} {{ t.price.value | price(t.price.currency) }} - {%- if t.is_paid_for %} + {%- if t.redeemed %} + Checked in + {%- elif t.is_paid_for %} Paid {%- elif t.refund %} Refunded From 619fcaeb97122c9a475e3af0414723f8acbdf4bb Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Sat, 8 Aug 2026 22:51:50 +0100 Subject: [PATCH 4/5] Add ability to filter refund handling to a single user for testing --- apps/tickets/tasks.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/tickets/tasks.py b/apps/tickets/tasks.py index 56c217072..8cb0f256c 100644 --- a/apps/tickets/tasks.py +++ b/apps/tickets/tasks.py @@ -535,7 +535,8 @@ def emf2026_refund_keebdecks_emailtest() -> None: @click.option( "--dry-run", is_flag=True, help="If set, don't actually refund/change the database, just simulate it." ) -def emf2026_refund_keebdecks(dry_run: bool = True) -> None: +@click.option("--single-user", help="Email address of a single user to process.") +def emf2026_refund_keebdecks(dry_run: bool = True, single_user: str = "") -> None: """Perform refunds/part refunds for keebdecks. For uncollected keebdecks we provide a full refund. @@ -561,9 +562,12 @@ def emf2026_refund_keebdecks(dry_run: bool = True) -> None: assert price keebless_price[currency] = price + query = select(User) + if single_user: + app.logger.info("%sFiltering refunds to just user %s", dry_run_prefix, single_user) + query = query.where(User.email == single_user) query = ( - select(User) - .join(User.owned_purchases) + query.join(User.owned_purchases) .where( Purchase.is_paid_for == True, Purchase.product.has(Product.name == keebdeck_product_name), From 8a8be6455b5141a0235e35fea88ce04e09d0b44c Mon Sep 17 00:00:00 2001 From: Luke Granger-Brown Date: Sat, 8 Aug 2026 22:55:07 +0100 Subject: [PATCH 5/5] Add final log summary of the total refunded amount --- apps/tickets/tasks.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/tickets/tasks.py b/apps/tickets/tasks.py index 8cb0f256c..6b2cfb6ed 100644 --- a/apps/tickets/tasks.py +++ b/apps/tickets/tasks.py @@ -578,6 +578,8 @@ def emf2026_refund_keebdecks(dry_run: bool = True, single_user: str = "") -> Non users = list(db.session.execute(query).unique().scalars()) + summary_total_amounts_by_currency: dict[Currency, Decimal] = defaultdict(Decimal) + def _redeemed_by_badge_on_sunday(purchase: Purchase) -> bool: """https://chat.orga.emfcamp.org/emf/pl/wamc3u9kgjfd7nre78w3od98nw""" if not purchase.redeemed: @@ -729,6 +731,16 @@ def _redeemed_by_badge_on_sunday(purchase: Purchase) -> bool: if not dry_run: msg.send() + for currency, value in total_amounts_by_currency.items(): + summary_total_amounts_by_currency[currency] += value + app.logger.info( + "%sTotal refund value: %s", + dry_run_prefix, + " and ".join( + f"{currency.symbol}{price:.2f}" for currency, price in summary_total_amounts_by_currency.items() + ), + ) + @tickets.cli.group() def googlewallet():