From fc2ed4afa9d2ae9d4f08677bc53161bc0a079c7f Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Wed, 20 Aug 2025 16:30:18 +0100 Subject: [PATCH 01/12] add extra column --- migrations.py | 11 +++++++++++ models.py | 28 +++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/migrations.py b/migrations.py index 87a0dd4..ae01356 100644 --- a/migrations.py +++ b/migrations.py @@ -160,3 +160,14 @@ async def m005_add_image_banner(db): Add a column to allow an image banner for the event """ await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;") + + +async def m006_add_extra_fields(db): + """ + Add an 'extra' column to events and ticket tables to support promo codes and ticket metadata. + """ + # Add 'extra' column to events table + await db.execute("ALTER TABLE events.events ADD COLUMN extra TEXT;") + + # Add 'extra' column to ticket table + await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;") diff --git a/models.py b/models.py index f0a52b2..f93bf04 100644 --- a/models.py +++ b/models.py @@ -1,7 +1,24 @@ from datetime import datetime from fastapi import Query -from pydantic import BaseModel, EmailStr +from pydantic import BaseModel, EmailStr, Field, field_validator + + +class PromoCode(BaseModel): + code: str + discount_percent: float = 0 + description: Optional[str] = None + + @field_validator("discount_percent") + def validate_discount_percent(cls, v): + assert 0 <= v <= 100, "Discount must be between 0 and 100." + return v + + +class EventExtra(BaseModel): + promo_codes: list[PromoCode] = Field(default_factory=list) + conditional: bool = False + min_tickets: int = 1 class CreateEvent(BaseModel): @@ -15,6 +32,7 @@ class CreateEvent(BaseModel): amount_tickets: int = Query(..., ge=0) price_per_ticket: float = Query(..., ge=0) banner: str | None = None + extra: EventExtra = Field(default_factory=EventExtra) class CreateTicket(BaseModel): @@ -36,6 +54,13 @@ class Event(BaseModel): time: datetime sold: int = 0 banner: str | None = None + extra: EventExtra = Field(default_factory=EventExtra) + + +class TicketExtra(BaseModel): + applied_promo_code: str | None = None + discount_applied: float | None = None + refund_address: str | None = None class Ticket(BaseModel): @@ -48,3 +73,4 @@ class Ticket(BaseModel): paid: bool time: datetime reg_timestamp: datetime + extra: TicketExtra = Field(default_factory=TicketExtra) From f10f472ecbe93953b18f90f1757ecf9e9a927684 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Wed, 20 Aug 2025 17:22:15 +0100 Subject: [PATCH 02/12] add conditional events --- models.py | 4 +-- static/js/index.js | 27 +++++++++++++---- templates/events/index.html | 59 +++++++++++++++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/models.py b/models.py index f93bf04..3e32515 100644 --- a/models.py +++ b/models.py @@ -1,7 +1,7 @@ from datetime import datetime from fastapi import Query -from pydantic import BaseModel, EmailStr, Field, field_validator +from pydantic import BaseModel, EmailStr, Field, validator class PromoCode(BaseModel): @@ -9,7 +9,7 @@ class PromoCode(BaseModel): discount_percent: float = 0 description: Optional[str] = None - @field_validator("discount_percent") + @validator("discount_percent") def validate_discount_percent(cls, v): assert 0 <= v <= 100, "Discount must be between 0 and 100." return v diff --git a/static/js/index.js b/static/js/index.js index cccbe82..c77576c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -161,20 +161,36 @@ window.app = Vue.createApp({ } }, + openEventDialog(data = false) { + if (data && data.id) { + this.formDialog.data = {...data} + } else { + this.formDialog.data = { + extra: { + conditional: false, + min_tickets: 1 + } + } + } + this.formDialog.show = true + }, + resetEventDialog() { + this.formDialog.show = false + this.formDialog.data = {} + }, + createEvent(wallet, data) { LNbits.api .request('POST', '/events/api/v1/events', wallet.adminkey, data) .then(response => { this.events.push(mapEvents(response.data)) - this.formDialog.show = false - this.formDialog.data = {} + this.openEventDialog() }) .catch(LNbits.utils.notifyApiError) }, updateformDialog(formId) { const link = _.findWhere(this.events, {id: formId}) - this.formDialog.data = {...link} - this.formDialog.show = true + this.openEventDialog(link) }, updateEvent(wallet, data) { LNbits.api @@ -189,8 +205,7 @@ window.app = Vue.createApp({ return obj.id == data.id }) this.events.push(mapEvents(response.data)) - this.formDialog.show = false - this.formDialog.data = {} + this.resetEventDialog() }) .catch(LNbits.utils.notifyApiError) }, diff --git a/templates/events/index.html b/templates/events/index.html index d9c6bb3..993408a 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -4,7 +4,7 @@
- New Event @@ -224,7 +224,6 @@
>
-
Event begins
@@ -248,6 +247,32 @@
>
+ +
@@ -283,6 +308,36 @@
>
+ +
+

+ Make this event conditional if + minimum tickets are sold. User will be asked to + provide a Lightning Address or LNURL pay for refunds. +

+
+ +
+
+ +
+
+
Date: Thu, 21 Aug 2025 14:58:27 +0100 Subject: [PATCH 03/12] refunds --- crud.py | 5 ++-- models.py | 15 ++++++----- services.py | 42 +++++++++++++++++++++++++++++- static/js/display.js | 2 ++ static/js/index.js | 3 ++- templates/events/display.html | 14 ++++++++-- templates/events/index.html | 1 + views.py | 27 +++++++++++++++++--- views_api.py | 48 ++++++++++++++++++++++++++++++----- 9 files changed, 135 insertions(+), 22 deletions(-) diff --git a/crud.py b/crud.py index 6d19761..3046f0e 100644 --- a/crud.py +++ b/crud.py @@ -3,13 +3,13 @@ from lnbits.db import Database from lnbits.helpers import urlsafe_short_hash -from .models import CreateEvent, Event, Ticket +from .models import CreateEvent, Event, Ticket, TicketExtra db = Database("ext_events") async def create_ticket( - payment_hash: str, wallet: str, event: str, name: str, email: str + payment_hash: str, wallet: str, event: str, name: str, email: str, extra: dict ) -> Ticket: now = datetime.now(timezone.utc) ticket = Ticket( @@ -22,6 +22,7 @@ async def create_ticket( paid=False, reg_timestamp=now, time=now, + extra=TicketExtra(**extra) if extra else TicketExtra(), ) await db.insert("events.ticket", ticket) return ticket diff --git a/models.py b/models.py index 3e32515..89a00c5 100644 --- a/models.py +++ b/models.py @@ -35,11 +35,6 @@ class CreateEvent(BaseModel): extra: EventExtra = Field(default_factory=EventExtra) -class CreateTicket(BaseModel): - name: str - email: EmailStr - - class Event(BaseModel): id: str wallet: str @@ -59,7 +54,15 @@ class Event(BaseModel): class TicketExtra(BaseModel): applied_promo_code: str | None = None - discount_applied: float | None = None + sats_paid: int | None = None + refund_address: str | None = None + refunded: bool = False + + +class CreateTicket(BaseModel): + name: str + email: EmailStr + promo_code: str | None = None refund_address: str | None = None diff --git a/services.py b/services.py index 1286534..27ea731 100644 --- a/services.py +++ b/services.py @@ -1,4 +1,13 @@ -from .crud import get_event, update_event, update_ticket +from lnurl import execute, execute_pay_request, handle +from loguru import logger + +from .crud import ( + get_event, + get_event_tickets, + purge_unpaid_tickets, + update_event, + update_ticket, +) from .models import Ticket @@ -16,3 +25,34 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket: await update_event(event) return ticket + + +async def refund_tickets(event_id: str): + """ + Refund tickets for an event that has not met the minimum ticket requirement. + This function should be called when the event is closed and the minimum ticket + condition is not met. + """ + event = await get_event(event_id) + if not event: + return + + await purge_unpaid_tickets(event_id) + tickets = await get_event_tickets(event_id) + + if not tickets: + return + + for ticket in tickets: + if ticket.extra.refunded: + continue + if ticket.paid and ticket.extra.refund_address and ticket.extra.sats_paid: + try: + res = await execute( + ticket.extra.refund_address, str(ticket.extra.sats_paid) + ) + if res: + ticket.extra.refunded = True + await update_ticket(ticket) + except Exception as e: + logger.error(f"Error refunding ticket {ticket.id}: {e}") diff --git a/static/js/display.js b/static/js/display.js index 4884751..c390632 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -29,7 +29,9 @@ window.app = Vue.createApp({ this.info = event_info this.info = this.info.substring(1, this.info.length - 1) this.banner = event_banner + this.extra = event_extra await this.purgeUnpaidTickets() + console.log(event_extra) }, computed: { formatDescription() { diff --git a/static/js/index.js b/static/js/index.js index c77576c..08b5d91 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -146,6 +146,7 @@ window.app = Vue.createApp({ this.events = response.data.map(function (obj) { return mapEvents(obj) }) + console.log(this.events) }) }, sendEventData() { @@ -184,7 +185,7 @@ window.app = Vue.createApp({ .request('POST', '/events/api/v1/events', wallet.adminkey, data) .then(response => { this.events.push(mapEvents(response.data)) - this.openEventDialog() + this.resetEventDialog() }) .catch(LNbits.utils.notifyApiError) }, diff --git a/templates/events/display.html b/templates/events/display.html index 0a2d8c7..1b8ac64 100644 --- a/templates/events/display.html +++ b/templates/events/display.html @@ -6,7 +6,7 @@

{{ event_name }}


-
+

@@ -30,7 +30,16 @@
Buy Ticket
:rules="[val => !!val || '* Required', val => emailValidation(val)]" lazy-rules > - +
Buy Ticket const event_name = '{{ event_name }}' const event_info = '{{ event_info | tojson }}' const event_banner = JSON.parse('{{ event_banner | tojson | safe }}') + const event_extra = JSON.parse('{{ event_extra | safe }}') {% endblock %} diff --git a/templates/events/index.html b/templates/events/index.html index 993408a..8740d80 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -337,6 +337,7 @@
>
+
diff --git a/views.py b/views.py index 707b591..2ae846a 100644 --- a/views.py +++ b/views.py @@ -2,13 +2,15 @@ from http import HTTPStatus from fastapi import APIRouter, Depends, Request +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + from lnbits.core.models import User from lnbits.decorators import check_user_exists from lnbits.helpers import template_renderer -from starlette.exceptions import HTTPException -from starlette.responses import HTMLResponse from .crud import get_event, get_ticket +from .services import refund_tickets events_generic_router = APIRouter() @@ -32,6 +34,13 @@ async def display(request: Request, event_id): status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." ) + is_window_open = ( + date.today() < datetime.strptime(event.closing_date, "%Y-%m-%d").date() + ) + is_min_tickets_met = ( + event.sold >= event.extra.min_tickets if event.extra.conditional else True + ) + if event.amount_tickets < 1: return events_renderer().TemplateResponse( "events/error.html", @@ -41,8 +50,7 @@ async def display(request: Request, event_id): "event_error": "Sorry, tickets are sold out :(", }, ) - datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date() - if date.today() > datetime_object: + if not is_window_open: return events_renderer().TemplateResponse( "events/error.html", { @@ -51,7 +59,17 @@ async def display(request: Request, event_id): "event_error": "Sorry, ticket closing date has passed :(", }, ) + if event.extra.conditional and not is_min_tickets_met and not is_window_open: + await refund_tickets(event_id) + return events_renderer().TemplateResponse( + "events/error.html", + { + "request": request, + "event_name": event.name, + "event_error": "Sorry, minimum ticket requirement not met.", + }, + ) return events_renderer().TemplateResponse( "events/display.html", { @@ -61,6 +79,7 @@ async def display(request: Request, event_id): "event_info": event.info, "event_price": event.price_per_ticket, "event_banner": event.banner, + "event_extra": event.extra.json(), }, ) diff --git a/views_api.py b/views_api.py index db4b34f..ee01390 100644 --- a/views_api.py +++ b/views_api.py @@ -2,6 +2,8 @@ from http import HTTPStatus from fastapi import APIRouter, Depends, Query +from starlette.exceptions import HTTPException + from lnbits.core.crud import get_standalone_payment, get_user from lnbits.core.models import WalletTypeInfo from lnbits.core.services import create_invoice @@ -13,7 +15,6 @@ fiat_amount_as_satoshis, get_fiat_rate_satoshis, ) -from starlette.exceptions import HTTPException from .crud import ( create_event, @@ -116,11 +117,15 @@ async def api_tickets( async def api_ticket_create(event_id: str, data: CreateTicket): name = data.name email = data.email - return await api_ticket_make_ticket(event_id, name, email) + promo_code = data.promo_code + refund_address = data.refund_address + return await api_ticket_make_ticket( + event_id, name, email, promo_code, refund_address + ) @events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}") -async def api_ticket_make_ticket(event_id, name, email): +async def api_ticket_make_ticket(event_id, name, email, promo_code, refund_address): event = await get_event(event_id) if not event: raise HTTPException( @@ -130,14 +135,25 @@ async def api_ticket_make_ticket(event_id, name, email): price = event.price_per_ticket extra = {"tag": "events", "name": name, "email": email} - if event.currency != "sats": - price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) + if promo_code: + # check if promo_code exists in event.extra.promo_codes + if promo_code not in [pc.code for pc in event.extra.promo_codes]: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, detail="Invalid promo code." + ) + # get the promocode + promo = next(pc for pc in event.extra.promo_codes if pc.code == promo_code) + extra["promo_code"] = promo.code + price = event.price_per_ticket * (1 - promo.discount_percent / 100) + if event.currency != "sats": extra["fiat"] = True extra["currency"] = event.currency - extra["fiatAmount"] = event.price_per_ticket + extra["fiatAmount"] = price extra["rate"] = await get_fiat_rate_satoshis(event.currency) + price = await fiat_amount_as_satoshis(price, event.currency) + try: payment = await create_invoice( wallet_id=event.wallet, @@ -151,6 +167,11 @@ async def api_ticket_make_ticket(event_id, name, email): event=event.id, name=name, email=email, + extra={ + "applied_promo_code": promo_code, + "refund_address": refund_address, + "sats_paid": int(price), + }, ) except Exception as exc: raise HTTPException( @@ -176,16 +197,31 @@ async def api_ticket_send_ticket(event_id, payment_hash): ) payment = await get_standalone_payment(payment_hash, incoming=True) assert payment + + if ticket.extra.applied_promo_code: + promo = next( + ( + pc + for pc in event.extra.promo_codes + if pc.code == ticket.extra.applied_promo_code + ), + None, + ) + if promo: + event.price_per_ticket *= 1 - promo.discount_percent / 100 + price = ( event.price_per_ticket * 1000 if event.currency == "sats" else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) * 1000 ) + # check if price is equal to payment.amount lower_bound = price * 0.99 # 1% decrease if not payment.pending and abs(payment.amount) >= lower_bound: # allow 1% error + ticket.extra.sats_paid = int(payment.amount / 1000) await set_ticket_paid(ticket) return {"paid": True, "ticket_id": ticket.id} From 7d2bb92d80901420628e39b8b18e9981b7d2c646 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Fri, 22 Aug 2025 09:16:16 +0100 Subject: [PATCH 04/12] . --- static/js/display.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/js/display.js b/static/js/display.js index c390632..710f637 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -9,7 +9,8 @@ window.app = Vue.createApp({ show: false, data: { name: '', - email: '' + email: '', + refund: '' } }, ticketLink: { @@ -43,6 +44,7 @@ window.app = Vue.createApp({ e.preventDefault() this.formDialog.data.name = '' this.formDialog.data.email = '' + this.formDialog.data.refund = '' }, closeReceiveDialog() { From 44d96b5d0f4052b903de6c6af07910e7bac70ca6 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Fri, 22 Aug 2025 18:01:21 +0100 Subject: [PATCH 05/12] uv stuff --- migrations.py | 3 ++- models.py | 2 +- services.py | 2 +- static/js/index.js | 6 +++--- views.py | 5 ++--- views_api.py | 3 +-- 6 files changed, 10 insertions(+), 11 deletions(-) diff --git a/migrations.py b/migrations.py index ae01356..6a9c32c 100644 --- a/migrations.py +++ b/migrations.py @@ -164,7 +164,8 @@ async def m005_add_image_banner(db): async def m006_add_extra_fields(db): """ - Add an 'extra' column to events and ticket tables to support promo codes and ticket metadata. + Add an 'extra' column to events and ticket tables + to support promo codes and ticket metadata. """ # Add 'extra' column to events table await db.execute("ALTER TABLE events.events ADD COLUMN extra TEXT;") diff --git a/models.py b/models.py index 89a00c5..4374e4d 100644 --- a/models.py +++ b/models.py @@ -7,7 +7,7 @@ class PromoCode(BaseModel): code: str discount_percent: float = 0 - description: Optional[str] = None + description: str | None = None @validator("discount_percent") def validate_discount_percent(cls, v): diff --git a/services.py b/services.py index 27ea731..8316e36 100644 --- a/services.py +++ b/services.py @@ -1,4 +1,4 @@ -from lnurl import execute, execute_pay_request, handle +from lnurl import execute from loguru import logger from .crud import ( diff --git a/static/js/index.js b/static/js/index.js index 08b5d91..8567702 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -20,8 +20,6 @@ window.app = Vue.createApp({ columns: [ {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, - {name: 'info', align: 'left', label: 'Info', field: 'info'}, - {name: 'banner', align: 'left', label: 'Banner', field: 'banner'}, { name: 'event_start_date', align: 'left', @@ -65,7 +63,9 @@ window.app = Vue.createApp({ align: 'left', label: 'Sold', field: 'sold' - } + }, + {name: 'info', align: 'left', label: 'Info', field: 'info'}, + {name: 'banner', align: 'left', label: 'Banner', field: 'banner'} ], pagination: { rowsPerPage: 10 diff --git a/views.py b/views.py index 2ae846a..4619196 100644 --- a/views.py +++ b/views.py @@ -2,12 +2,11 @@ from http import HTTPStatus from fastapi import APIRouter, Depends, Request -from starlette.exceptions import HTTPException -from starlette.responses import HTMLResponse - from lnbits.core.models import User from lnbits.decorators import check_user_exists from lnbits.helpers import template_renderer +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse from .crud import get_event, get_ticket from .services import refund_tickets diff --git a/views_api.py b/views_api.py index ee01390..c058f28 100644 --- a/views_api.py +++ b/views_api.py @@ -2,8 +2,6 @@ from http import HTTPStatus from fastapi import APIRouter, Depends, Query -from starlette.exceptions import HTTPException - from lnbits.core.crud import get_standalone_payment, get_user from lnbits.core.models import WalletTypeInfo from lnbits.core.services import create_invoice @@ -15,6 +13,7 @@ fiat_amount_as_satoshis, get_fiat_rate_satoshis, ) +from starlette.exceptions import HTTPException from .crud import ( create_event, From a28d1ca39ecf09f246ba96a74e442380dfe894a4 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Mon, 25 Aug 2025 12:46:12 +0100 Subject: [PATCH 06/12] conditional events working --- migrations.py | 12 +++++-- models.py | 1 + services.py | 4 --- static/js/display.js | 10 ------ static/js/index.js | 68 ++++++++++++++++++++++++++++++++++--- templates/events/index.html | 28 +-------------- views.py | 23 ++++++++----- views_api.py | 37 ++++++++++++-------- 8 files changed, 112 insertions(+), 71 deletions(-) diff --git a/migrations.py b/migrations.py index 6a9c32c..474da54 100644 --- a/migrations.py +++ b/migrations.py @@ -164,11 +164,17 @@ async def m005_add_image_banner(db): async def m006_add_extra_fields(db): """ - Add an 'extra' column to events and ticket tables + Add a canceled and 'extra' column to events and ticket tables to support promo codes and ticket metadata. """ - # Add 'extra' column to events table - await db.execute("ALTER TABLE events.events ADD COLUMN extra TEXT;") + # Add canceled and 'extra' columns to events table + await db.execute( + """ + ALTER TABLE events.events + ADD COLUMN canceled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN extra TEXT; + """ + ) # Add 'extra' column to ticket table await db.execute("ALTER TABLE events.ticket ADD COLUMN extra TEXT;") diff --git a/models.py b/models.py index 4374e4d..0f49e6e 100644 --- a/models.py +++ b/models.py @@ -41,6 +41,7 @@ class Event(BaseModel): name: str info: str closing_date: str + canceled: bool = False event_start_date: str event_end_date: str currency: str diff --git a/services.py b/services.py index 8316e36..9099ef0 100644 --- a/services.py +++ b/services.py @@ -33,10 +33,6 @@ async def refund_tickets(event_id: str): This function should be called when the event is closed and the minimum ticket condition is not met. """ - event = await get_event(event_id) - if not event: - return - await purge_unpaid_tickets(event_id) tickets = await get_event_tickets(event_id) diff --git a/static/js/display.js b/static/js/display.js index 710f637..900e991 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -31,8 +31,6 @@ window.app = Vue.createApp({ this.info = this.info.substring(1, this.info.length - 1) this.banner = event_banner this.extra = event_extra - await this.purgeUnpaidTickets() - console.log(event_extra) }, computed: { formatDescription() { @@ -64,7 +62,6 @@ window.app = Vue.createApp({ const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/ return regex.test(val) || 'Please enter valid email.' }, - Invoice() { axios .post(`/events/api/v1/tickets/${event_id}`, { @@ -126,13 +123,6 @@ window.app = Vue.createApp({ }, 2000) }) .catch(LNbits.utils.notifyApiError) - }, - async purgeUnpaidTickets() { - try { - await LNbits.api.request('GET', `/events/api/v1/purge/${event_id}`) - } catch (error) { - LNbits.utils.notifyApiError(error) - } } } }) diff --git a/static/js/index.js b/static/js/index.js index 8567702..d56a50b 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,8 +1,5 @@ const mapEvents = function (obj) { - obj.date = Quasar.date.formatDate( - new Date(obj.time * 1000), - 'YYYY-MM-DD HH:mm' - ) + obj.date = Quasar.date.formatDate(new Date(obj.time), 'YYYY-MM-DD HH:mm') obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.price_per_ticket) obj.displayUrl = ['/events/', obj.id].join('') return obj @@ -38,6 +35,17 @@ window.app = Vue.createApp({ label: 'Ticket close', field: 'closing_date' }, + { + name: 'canceled', + align: 'left', + label: 'Canceled', + field: row => { + if (row.extra.conditional && row.canceled) { + return 'Yes' + } + return 'No' + } + }, { name: 'price_per_ticket', align: 'left', @@ -147,6 +155,7 @@ window.app = Vue.createApp({ return mapEvents(obj) }) console.log(this.events) + this.checkCanceledEvents() }) }, sendEventData() { @@ -232,6 +241,30 @@ window.app = Vue.createApp({ }, exporteventsCSV() { LNbits.utils.exportCSV(this.eventsTable.columns, this.events) + }, + async checkCanceledEvents() { + const events = this.events + .filter(event => event.extra.conditional) + .filter(e => !e.canceled) + if (!events.length) return + const now = new Date() + events.forEach(async ev => { + if (new Date(ev.closing_date) < now && ev.sold < ev.extra.min_tickets) { + const {data} = await LNbits.api.request( + 'PUT', + '/events/api/v1/events/' + ev.id + '/cancel', + _.findWhere(this.g.user.wallets, {id: ev.wallet}).adminkey + ) + Quasar.Notify.create({ + type: 'warning', + message: `Event ${ev.name} has been canceled and refunds have been issued.`, + icon: null + }) + this.events = this.events.map(e => + e.id === ev.id ? mapEvents(data) : e + ) + } + }) } }, async created() { @@ -242,3 +275,30 @@ window.app = Vue.createApp({ } } }) + +/* +{ + "id": "agNkaiTXbxa8KShW4grZUQ", + "wallet": "12f58510dc5a46ffb8e95e7fc336c3da", + "name": "test conditional", + "info": "## Conditional Event\n\nMust sell 5 tickets\n\nAsks for refund lnaddress", + "closing_date": "2025-08-22", + "canceled": false, + "event_start_date": "2025-08-23", + "event_end_date": "2025-08-31", + "currency": "EUR", + "amount_tickets": 10, + "price_per_ticket": 0.10000000149011612, + "time": "2025-08-21T09:22:26.863944", + "sold": 0, + "banner": null, + "extra": { + "promo_codes": [], + "conditional": true, + "min_tickets": 5 + }, + "date": "2025-08-21 09:22", + "fsat": "0.1", + "displayUrl": "/events/agNkaiTXbxa8KShW4grZUQ" +} +*/ diff --git a/templates/events/index.html b/templates/events/index.html index 8740d80..2679980 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -247,33 +247,6 @@
>
- - -
label="Advanced options" >
+
Conditional Events

Make this event conditional if minimum tickets are sold. User will be asked to diff --git a/views.py b/views.py index 4619196..97c8e07 100644 --- a/views.py +++ b/views.py @@ -2,13 +2,14 @@ from http import HTTPStatus from fastapi import APIRouter, Depends, Request +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse + from lnbits.core.models import User from lnbits.decorators import check_user_exists from lnbits.helpers import template_renderer -from starlette.exceptions import HTTPException -from starlette.responses import HTMLResponse -from .crud import get_event, get_ticket +from .crud import get_event, get_ticket, purge_unpaid_tickets, update_event from .services import refund_tickets events_generic_router = APIRouter() @@ -33,6 +34,8 @@ async def display(request: Request, event_id): status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." ) + await purge_unpaid_tickets(event_id) + is_window_open = ( date.today() < datetime.strptime(event.closing_date, "%Y-%m-%d").date() ) @@ -49,24 +52,26 @@ async def display(request: Request, event_id): "event_error": "Sorry, tickets are sold out :(", }, ) - if not is_window_open: + if event.extra.conditional and not is_min_tickets_met and not is_window_open: + event.canceled = True + await update_event(event) + await refund_tickets(event_id) + return events_renderer().TemplateResponse( "events/error.html", { "request": request, "event_name": event.name, - "event_error": "Sorry, ticket closing date has passed :(", + "event_error": "Sorry, event was cancelled.", }, ) - if event.extra.conditional and not is_min_tickets_met and not is_window_open: - await refund_tickets(event_id) - + if not is_window_open: return events_renderer().TemplateResponse( "events/error.html", { "request": request, "event_name": event.name, - "event_error": "Sorry, minimum ticket requirement not met.", + "event_error": "Sorry, ticket closing date has passed :(", }, ) return events_renderer().TemplateResponse( diff --git a/views_api.py b/views_api.py index c058f28..fcc2153 100644 --- a/views_api.py +++ b/views_api.py @@ -2,6 +2,8 @@ from http import HTTPStatus from fastapi import APIRouter, Depends, Query +from starlette.exceptions import HTTPException + from lnbits.core.crud import get_standalone_payment, get_user from lnbits.core.models import WalletTypeInfo from lnbits.core.services import create_invoice @@ -13,7 +15,6 @@ fiat_amount_as_satoshis, get_fiat_rate_satoshis, ) -from starlette.exceptions import HTTPException from .crud import ( create_event, @@ -26,12 +27,11 @@ get_events, get_ticket, get_tickets, - purge_unpaid_tickets, update_event, update_ticket, ) from .models import CreateEvent, CreateTicket, Ticket -from .services import set_ticket_paid +from .services import refund_tickets, set_ticket_paid events_api_router = APIRouter() @@ -77,6 +77,26 @@ async def api_event_create( return event.dict() +@events_api_router.put("/api/v1/events/{event_id}/cancel") +async def api_event_cancel( + event_id: str, + wallet: WalletTypeInfo = Depends(require_admin_key), +): + event = await get_event(event_id) + if not event: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." + ) + + if event.wallet != wallet.wallet.id: + raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail="Not your event.") + event.canceled = True + event = await update_event(event) + await refund_tickets(event.id) + + return event.dict() + + @events_api_router.delete("/api/v1/events/{event_id}") async def api_form_delete( event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) @@ -243,17 +263,6 @@ async def api_ticket_delete( await delete_ticket(ticket_id) -# TODO: DELETE, updates db! @tal -@events_api_router.get("/api/v1/purge/{event_id}") -async def api_event_purge_tickets(event_id: str): - event = await get_event(event_id) - if not event: - raise HTTPException( - status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." - ) - return await purge_unpaid_tickets(event_id) - - @events_api_router.get("/api/v1/eventtickets/{event_id}") async def api_event_tickets(event_id: str) -> list[Ticket]: return await get_event_tickets(event_id) From 00dc55450fc004e90e21090fef35235de4c312ba Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Wed, 3 Sep 2025 09:15:33 +0100 Subject: [PATCH 07/12] adding promo codes --- models.py | 1 + static/js/index.js | 3 +- templates/events/index.html | 87 ++++++++++++++++++++++++++++++++++++- 3 files changed, 88 insertions(+), 3 deletions(-) diff --git a/models.py b/models.py index 0f49e6e..fd10417 100644 --- a/models.py +++ b/models.py @@ -8,6 +8,7 @@ class PromoCode(BaseModel): code: str discount_percent: float = 0 description: str | None = None + active: bool = True @validator("discount_percent") def validate_discount_percent(cls, v): diff --git a/static/js/index.js b/static/js/index.js index d56a50b..0f9fff4 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -178,7 +178,8 @@ window.app = Vue.createApp({ this.formDialog.data = { extra: { conditional: false, - min_tickets: 1 + min_tickets: 1, + promo_codes: [] } } } diff --git a/templates/events/index.html b/templates/events/index.html index 2679980..226e010 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -33,7 +33,7 @@

Events
@@ -311,7 +363,38 @@
>
- +
From ed4236a98e93070215d21cece7190a5ebd0f2fc2 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Sat, 6 Dec 2025 13:20:09 +0000 Subject: [PATCH 08/12] promo codes logic --- models.py | 8 +- static/js/display.js | 4 +- static/js/index.js | 62 ++++++------ templates/events/display.html | 8 ++ templates/events/index.html | 173 ++++++++++++++++++++-------------- views.py | 8 ++ views_api.py | 2 +- 7 files changed, 158 insertions(+), 107 deletions(-) diff --git a/models.py b/models.py index fd10417..f82890e 100644 --- a/models.py +++ b/models.py @@ -6,10 +6,14 @@ class PromoCode(BaseModel): code: str - discount_percent: float = 0 - description: str | None = None + discount_percent: float = 0.0 active: bool = True + # make the promo code uppercase + @validator("code") + def uppercase_code(cls, v): + return v.upper() + @validator("discount_percent") def validate_discount_percent(cls, v): assert 0 <= v <= 100, "Discount must be between 0 and 100." diff --git a/static/js/display.js b/static/js/display.js index 900e991..6098e5a 100644 --- a/static/js/display.js +++ b/static/js/display.js @@ -31,6 +31,7 @@ window.app = Vue.createApp({ this.info = this.info.substring(1, this.info.length - 1) this.banner = event_banner this.extra = event_extra + this.hasPromoCodes = has_promoCodes }, computed: { formatDescription() { @@ -66,7 +67,8 @@ window.app = Vue.createApp({ axios .post(`/events/api/v1/tickets/${event_id}`, { name: this.formDialog.data.name, - email: this.formDialog.data.email + email: this.formDialog.data.email, + promo_code: this.formDialog.data.promo_code || null }) .then(response => { this.paymentReq = response.data.payment_request diff --git a/static/js/index.js b/static/js/index.js index 0f9fff4..4b78f6e 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,6 +1,8 @@ const mapEvents = function (obj) { obj.date = Quasar.date.formatDate(new Date(obj.time), 'YYYY-MM-DD HH:mm') - obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.price_per_ticket) + obj.fsat = new Intl.NumberFormat(navigator.language).format( + obj.price_per_ticket + ) obj.displayUrl = ['/events/', obj.id].join('') return obj } @@ -81,7 +83,6 @@ window.app = Vue.createApp({ }, ticketsTable: { columns: [ - {name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'event', align: 'left', label: 'Event', field: 'event'}, {name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'email', align: 'left', label: 'Email', field: 'email'}, @@ -90,7 +91,14 @@ window.app = Vue.createApp({ align: 'left', label: 'Registered', field: 'registered' - } + }, + { + name: 'promo_code', + align: 'left', + label: 'Promo Code', + field: row => row.extra.applied_promo_code || '' + }, + {name: 'id', align: 'left', label: 'ID', field: 'id'} ], pagination: { rowsPerPage: 10 @@ -98,7 +106,11 @@ window.app = Vue.createApp({ }, formDialog: { show: false, - data: {} + data: { + extra: { + promo_codes: [] + } + } } } }, @@ -116,6 +128,7 @@ window.app = Vue.createApp({ return mapEvents(obj) }) .filter(e => e.paid) + console.log('Tickets: ', this.tickets) }) }, deleteTicket(ticketId) { @@ -151,7 +164,8 @@ window.app = Vue.createApp({ this.g.user.wallets[0].inkey ) .then(response => { - this.events = response.data.map(function (obj) { + console.log(response.data) + this.events = response.data.map(obj => { return mapEvents(obj) }) console.log(this.events) @@ -163,6 +177,11 @@ window.app = Vue.createApp({ id: this.formDialog.data.wallet }) const data = this.formDialog.data + if (data.extra && !data.extra.promo_codes) { + data.extra.promo_codes = data.extra.promo_codes + .filter(code => code.trim() !== '') + .map(code => code.trim().toUpperCase()) + } if (data.id) { this.updateEvent(wallet, data) @@ -187,7 +206,11 @@ window.app = Vue.createApp({ }, resetEventDialog() { this.formDialog.show = false - this.formDialog.data = {} + this.formDialog.data = { + extra: { + promo_codes: [] + } + } }, createEvent(wallet, data) { @@ -276,30 +299,3 @@ window.app = Vue.createApp({ } } }) - -/* -{ - "id": "agNkaiTXbxa8KShW4grZUQ", - "wallet": "12f58510dc5a46ffb8e95e7fc336c3da", - "name": "test conditional", - "info": "## Conditional Event\n\nMust sell 5 tickets\n\nAsks for refund lnaddress", - "closing_date": "2025-08-22", - "canceled": false, - "event_start_date": "2025-08-23", - "event_end_date": "2025-08-31", - "currency": "EUR", - "amount_tickets": 10, - "price_per_ticket": 0.10000000149011612, - "time": "2025-08-21T09:22:26.863944", - "sold": 0, - "banner": null, - "extra": { - "promo_codes": [], - "conditional": true, - "min_tickets": 5 - }, - "date": "2025-08-21 09:22", - "fsat": "0.1", - "displayUrl": "/events/agNkaiTXbxa8KShW4grZUQ" -} -*/ diff --git a/templates/events/display.html b/templates/events/display.html index 1b8ac64..0a70cf0 100644 --- a/templates/events/display.html +++ b/templates/events/display.html @@ -40,6 +40,13 @@
Buy Ticket
lazy-rules :hint="`If minimum tickets (${this.extra?.min_tickets}) are not met, refund will be sent.`" > +
Buy Ticket const event_info = '{{ event_info | tojson }}' const event_banner = JSON.parse('{{ event_banner | tojson | safe }}') const event_extra = JSON.parse('{{ event_extra | safe }}') + const has_promoCodes = {{ has_promo_codes | tojson }} {% endblock %} diff --git a/templates/events/index.html b/templates/events/index.html index 226e010..62752d1 100644 --- a/templates/events/index.html +++ b/templates/events/index.html @@ -100,45 +100,49 @@
Events
- - -
-
Promo Codes
-

- Allow users to enter a promo code for discounts. -

- +
+ Status: + +
+
- - Action 1 - - +
@@ -330,6 +334,7 @@
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'" fill-mask="0" reverse-fill-mask + :disable="formDialog.data.currency == null" >
@@ -339,12 +344,12 @@
label="Advanced options" >
-
Conditional Events
-

+

Conditional Events
+
Make this event conditional if minimum tickets are sold. User will be asked to provide a Lightning Address or LNURL pay for refunds. -

+
>
- + flat + icon="delete" + @click="formDialog.data.extra.promo_codes.splice(index, 1)" + > + + + +
+ Add Promo Code +
diff --git a/views.py b/views.py index 97c8e07..55b927c 100644 --- a/views.py +++ b/views.py @@ -74,6 +74,13 @@ async def display(request: Request, event_id): "event_error": "Sorry, ticket closing date has passed :(", }, ) + + if len(event.extra.promo_codes) > 0: + has_promo_codes = True + else: + has_promo_codes = False + + event.extra.promo_codes = [] return events_renderer().TemplateResponse( "events/display.html", { @@ -84,6 +91,7 @@ async def display(request: Request, event_id): "event_price": event.price_per_ticket, "event_banner": event.banner, "event_extra": event.extra.json(), + "has_promo_codes": has_promo_codes, }, ) diff --git a/views_api.py b/views_api.py index fcc2153..7d5c2a4 100644 --- a/views_api.py +++ b/views_api.py @@ -136,7 +136,7 @@ async def api_tickets( async def api_ticket_create(event_id: str, data: CreateTicket): name = data.name email = data.email - promo_code = data.promo_code + promo_code = data.promo_code.upper() if data.promo_code else None refund_address = data.refund_address return await api_ticket_make_ticket( event_id, name, email, promo_code, refund_address From 5bec4a3bf4f9e077e02855a09527bb2ab7ac9330 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Sat, 6 Dec 2025 13:20:54 +0000 Subject: [PATCH 09/12] chore: linter --- views.py | 5 ++--- views_api.py | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/views.py b/views.py index 55b927c..0680dcc 100644 --- a/views.py +++ b/views.py @@ -2,12 +2,11 @@ from http import HTTPStatus from fastapi import APIRouter, Depends, Request -from starlette.exceptions import HTTPException -from starlette.responses import HTMLResponse - from lnbits.core.models import User from lnbits.decorators import check_user_exists from lnbits.helpers import template_renderer +from starlette.exceptions import HTTPException +from starlette.responses import HTMLResponse from .crud import get_event, get_ticket, purge_unpaid_tickets, update_event from .services import refund_tickets diff --git a/views_api.py b/views_api.py index 7d5c2a4..58c9bca 100644 --- a/views_api.py +++ b/views_api.py @@ -2,8 +2,6 @@ from http import HTTPStatus from fastapi import APIRouter, Depends, Query -from starlette.exceptions import HTTPException - from lnbits.core.crud import get_standalone_payment, get_user from lnbits.core.models import WalletTypeInfo from lnbits.core.services import create_invoice @@ -15,6 +13,7 @@ fiat_amount_as_satoshis, get_fiat_rate_satoshis, ) +from starlette.exceptions import HTTPException from .crud import ( create_event, From 62bd8dddbd7696841d5cd0e5d26e6c02008f8bd4 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 9 Dec 2025 10:02:38 +0000 Subject: [PATCH 10/12] Update static/js/index.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: dni ⚡ --- static/js/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/index.js b/static/js/index.js index 4b78f6e..73982bd 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,6 +1,6 @@ const mapEvents = function (obj) { obj.date = Quasar.date.formatDate(new Date(obj.time), 'YYYY-MM-DD HH:mm') - obj.fsat = new Intl.NumberFormat(navigator.language).format( + obj.fsat = new Intl.NumberFormat(window.g.locale).format( obj.price_per_ticket ) obj.displayUrl = ['/events/', obj.id].join('') From ad76e5ae4c59efdde7020d4a0c943d3f6029b748 Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 9 Dec 2025 10:02:49 +0000 Subject: [PATCH 11/12] Update static/js/index.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: dni ⚡ --- static/js/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/js/index.js b/static/js/index.js index 73982bd..7117689 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,5 +1,5 @@ const mapEvents = function (obj) { - obj.date = Quasar.date.formatDate(new Date(obj.time), 'YYYY-MM-DD HH:mm') + obj.date = LNbits.utils.formatTimestamp(obj.time) obj.fsat = new Intl.NumberFormat(window.g.locale).format( obj.price_per_ticket ) From dc9165e7607b28da834068400e6a4a665ecc612a Mon Sep 17 00:00:00 2001 From: Tiago Vasconcelos Date: Tue, 9 Dec 2025 10:07:07 +0000 Subject: [PATCH 12/12] clean logs --- static/js/index.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index 7117689..d26133c 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,8 +1,6 @@ const mapEvents = function (obj) { obj.date = LNbits.utils.formatTimestamp(obj.time) - obj.fsat = new Intl.NumberFormat(window.g.locale).format( - obj.price_per_ticket - ) + obj.fsat = new Intl.NumberFormat(window.g.locale).format(obj.price_per_ticket) obj.displayUrl = ['/events/', obj.id].join('') return obj } @@ -128,7 +126,6 @@ window.app = Vue.createApp({ return mapEvents(obj) }) .filter(e => e.paid) - console.log('Tickets: ', this.tickets) }) }, deleteTicket(ticketId) { @@ -164,11 +161,9 @@ window.app = Vue.createApp({ this.g.user.wallets[0].inkey ) .then(response => { - console.log(response.data) this.events = response.data.map(obj => { return mapEvents(obj) }) - console.log(this.events) this.checkCanceledEvents() }) },