feat: add promo codes and conditional events (#40)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled

* add extra column
* add conditional events
* refunds
* conditional events working
* adding promo codes
* promo codes logic

---------

Co-authored-by: dni  <office@dnilabs.com>
This commit is contained in:
Tiago Vasconcelos 2025-12-09 10:48:00 +00:00 committed by GitHub
commit 42de6d4791
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 456 additions and 65 deletions

View file

@ -3,13 +3,13 @@ from datetime import datetime, timedelta, timezone
from lnbits.db import Database from lnbits.db import Database
from lnbits.helpers import urlsafe_short_hash from lnbits.helpers import urlsafe_short_hash
from .models import CreateEvent, Event, Ticket from .models import CreateEvent, Event, Ticket, TicketExtra
db = Database("ext_events") db = Database("ext_events")
async def create_ticket( 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: ) -> Ticket:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ticket = Ticket( ticket = Ticket(
@ -22,6 +22,7 @@ async def create_ticket(
paid=False, paid=False,
reg_timestamp=now, reg_timestamp=now,
time=now, time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
) )
await db.insert("events.ticket", ticket) await db.insert("events.ticket", ticket)
return ticket return ticket

View file

@ -160,3 +160,21 @@ async def m005_add_image_banner(db):
Add a column to allow an image banner for the event Add a column to allow an image banner for the event
""" """
await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;") await db.execute("ALTER TABLE events.events ADD COLUMN banner TEXT;")
async def m006_add_extra_fields(db):
"""
Add a canceled and 'extra' column to events and ticket tables
to support promo codes and ticket metadata.
"""
# 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;")

View file

@ -1,7 +1,29 @@
from datetime import datetime from datetime import datetime
from fastapi import Query from fastapi import Query
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr, Field, validator
class PromoCode(BaseModel):
code: str
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."
return v
class EventExtra(BaseModel):
promo_codes: list[PromoCode] = Field(default_factory=list)
conditional: bool = False
min_tickets: int = 1
class CreateEvent(BaseModel): class CreateEvent(BaseModel):
@ -15,11 +37,7 @@ class CreateEvent(BaseModel):
amount_tickets: int = Query(..., ge=0) amount_tickets: int = Query(..., ge=0)
price_per_ticket: float = Query(..., ge=0) price_per_ticket: float = Query(..., ge=0)
banner: str | None = None banner: str | None = None
extra: EventExtra = Field(default_factory=EventExtra)
class CreateTicket(BaseModel):
name: str
email: EmailStr
class Event(BaseModel): class Event(BaseModel):
@ -28,6 +46,7 @@ class Event(BaseModel):
name: str name: str
info: str info: str
closing_date: str closing_date: str
canceled: bool = False
event_start_date: str event_start_date: str
event_end_date: str event_end_date: str
currency: str currency: str
@ -36,6 +55,21 @@ class Event(BaseModel):
time: datetime time: datetime
sold: int = 0 sold: int = 0
banner: str | None = None banner: str | None = None
extra: EventExtra = Field(default_factory=EventExtra)
class TicketExtra(BaseModel):
applied_promo_code: str | 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
class Ticket(BaseModel): class Ticket(BaseModel):
@ -48,3 +82,4 @@ class Ticket(BaseModel):
paid: bool paid: bool
time: datetime time: datetime
reg_timestamp: datetime reg_timestamp: datetime
extra: TicketExtra = Field(default_factory=TicketExtra)

View file

@ -1,4 +1,13 @@
from .crud import get_event, update_event, update_ticket from lnurl import execute
from loguru import logger
from .crud import (
get_event,
get_event_tickets,
purge_unpaid_tickets,
update_event,
update_ticket,
)
from .models import Ticket from .models import Ticket
@ -16,3 +25,30 @@ async def set_ticket_paid(ticket: Ticket) -> Ticket:
await update_event(event) await update_event(event)
return ticket 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.
"""
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}")

View file

@ -9,7 +9,8 @@ window.app = Vue.createApp({
show: false, show: false,
data: { data: {
name: '', name: '',
email: '' email: '',
refund: ''
} }
}, },
ticketLink: { ticketLink: {
@ -29,7 +30,8 @@ window.app = Vue.createApp({
this.info = event_info this.info = event_info
this.info = this.info.substring(1, this.info.length - 1) this.info = this.info.substring(1, this.info.length - 1)
this.banner = event_banner this.banner = event_banner
await this.purgeUnpaidTickets() this.extra = event_extra
this.hasPromoCodes = has_promoCodes
}, },
computed: { computed: {
formatDescription() { formatDescription() {
@ -41,6 +43,7 @@ window.app = Vue.createApp({
e.preventDefault() e.preventDefault()
this.formDialog.data.name = '' this.formDialog.data.name = ''
this.formDialog.data.email = '' this.formDialog.data.email = ''
this.formDialog.data.refund = ''
}, },
closeReceiveDialog() { closeReceiveDialog() {
@ -60,12 +63,12 @@ window.app = Vue.createApp({
const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/ const regex = /^[\w\.-]+@[a-zA-Z\d\.-]+\.[a-zA-Z]{2,}$/
return regex.test(val) || 'Please enter valid email.' return regex.test(val) || 'Please enter valid email.'
}, },
Invoice() { Invoice() {
axios axios
.post(`/events/api/v1/tickets/${event_id}`, { .post(`/events/api/v1/tickets/${event_id}`, {
name: this.formDialog.data.name, 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 => { .then(response => {
this.paymentReq = response.data.payment_request this.paymentReq = response.data.payment_request
@ -122,13 +125,6 @@ window.app = Vue.createApp({
}, 2000) }, 2000)
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
},
async purgeUnpaidTickets() {
try {
await LNbits.api.request('GET', `/events/api/v1/purge/${event_id}`)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
} }
} }
}) })

View file

@ -1,9 +1,6 @@
const mapEvents = function (obj) { const mapEvents = function (obj) {
obj.date = Quasar.date.formatDate( obj.date = LNbits.utils.formatTimestamp(obj.time)
new Date(obj.time * 1000), obj.fsat = new Intl.NumberFormat(window.g.locale).format(obj.price_per_ticket)
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.price_per_ticket)
obj.displayUrl = ['/events/', obj.id].join('') obj.displayUrl = ['/events/', obj.id].join('')
return obj return obj
} }
@ -20,8 +17,6 @@ window.app = Vue.createApp({
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'}, {name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {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', name: 'event_start_date',
align: 'left', align: 'left',
@ -40,6 +35,17 @@ window.app = Vue.createApp({
label: 'Ticket close', label: 'Ticket close',
field: 'closing_date' 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', name: 'price_per_ticket',
align: 'left', align: 'left',
@ -65,7 +71,9 @@ window.app = Vue.createApp({
align: 'left', align: 'left',
label: 'Sold', label: 'Sold',
field: 'sold' field: 'sold'
} },
{name: 'info', align: 'left', label: 'Info', field: 'info'},
{name: 'banner', align: 'left', label: 'Banner', field: 'banner'}
], ],
pagination: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
@ -73,7 +81,6 @@ window.app = Vue.createApp({
}, },
ticketsTable: { ticketsTable: {
columns: [ columns: [
{name: 'id', align: 'left', label: 'ID', field: 'id'},
{name: 'event', align: 'left', label: 'Event', field: 'event'}, {name: 'event', align: 'left', label: 'Event', field: 'event'},
{name: 'name', align: 'left', label: 'Name', field: 'name'}, {name: 'name', align: 'left', label: 'Name', field: 'name'},
{name: 'email', align: 'left', label: 'Email', field: 'email'}, {name: 'email', align: 'left', label: 'Email', field: 'email'},
@ -82,7 +89,14 @@ window.app = Vue.createApp({
align: 'left', align: 'left',
label: 'Registered', label: 'Registered',
field: '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: { pagination: {
rowsPerPage: 10 rowsPerPage: 10
@ -90,7 +104,11 @@ window.app = Vue.createApp({
}, },
formDialog: { formDialog: {
show: false, show: false,
data: {} data: {
extra: {
promo_codes: []
}
}
} }
} }
}, },
@ -143,9 +161,10 @@ window.app = Vue.createApp({
this.g.user.wallets[0].inkey this.g.user.wallets[0].inkey
) )
.then(response => { .then(response => {
this.events = response.data.map(function (obj) { this.events = response.data.map(obj => {
return mapEvents(obj) return mapEvents(obj)
}) })
this.checkCanceledEvents()
}) })
}, },
sendEventData() { sendEventData() {
@ -153,6 +172,11 @@ window.app = Vue.createApp({
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
const data = this.formDialog.data 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) { if (data.id) {
this.updateEvent(wallet, data) this.updateEvent(wallet, data)
@ -161,20 +185,41 @@ 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,
promo_codes: []
}
}
}
this.formDialog.show = true
},
resetEventDialog() {
this.formDialog.show = false
this.formDialog.data = {
extra: {
promo_codes: []
}
}
},
createEvent(wallet, data) { createEvent(wallet, data) {
LNbits.api LNbits.api
.request('POST', '/events/api/v1/events', wallet.adminkey, data) .request('POST', '/events/api/v1/events', wallet.adminkey, data)
.then(response => { .then(response => {
this.events.push(mapEvents(response.data)) this.events.push(mapEvents(response.data))
this.formDialog.show = false this.resetEventDialog()
this.formDialog.data = {}
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
updateformDialog(formId) { updateformDialog(formId) {
const link = _.findWhere(this.events, {id: formId}) const link = _.findWhere(this.events, {id: formId})
this.formDialog.data = {...link} this.openEventDialog(link)
this.formDialog.show = true
}, },
updateEvent(wallet, data) { updateEvent(wallet, data) {
LNbits.api LNbits.api
@ -189,8 +234,7 @@ window.app = Vue.createApp({
return obj.id == data.id return obj.id == data.id
}) })
this.events.push(mapEvents(response.data)) this.events.push(mapEvents(response.data))
this.formDialog.show = false this.resetEventDialog()
this.formDialog.data = {}
}) })
.catch(LNbits.utils.notifyApiError) .catch(LNbits.utils.notifyApiError)
}, },
@ -216,6 +260,30 @@ window.app = Vue.createApp({
}, },
exporteventsCSV() { exporteventsCSV() {
LNbits.utils.exportCSV(this.eventsTable.columns, this.events) 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() { async created() {

View file

@ -6,7 +6,7 @@
<q-card-section class="q-pa-none"> <q-card-section class="q-pa-none">
<h3 class="q-my-none q-pa-lg">{{ event_name }}</h3> <h3 class="q-my-none q-pa-lg">{{ event_name }}</h3>
<br /> <br />
<div v-html="formatDescription"></div> <div v-html="formatDescription" class="q-pa-md"></div>
<br /> <br />
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -30,7 +30,23 @@
:rules="[val => !!val || '* Required', val => emailValidation(val)]" :rules="[val => !!val || '* Required', val => emailValidation(val)]"
lazy-rules lazy-rules
></q-input> ></q-input>
<q-input
v-if="this.extra?.conditional"
filled
dense
v-model.trim="formDialog.data.refund"
label="Refund lnadress or LNURL "
:rules="[val => !!val || '* Required']"
lazy-rules
:hint="`If minimum tickets (${this.extra?.min_tickets}) are not met, refund will be sent.`"
></q-input>
<q-input
v-if="hasPromoCodes"
filled
dense
v-model.trim="formDialog.data.promo_code"
label="Apply Promo Code "
></q-input>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn
unelevated unelevated
@ -93,6 +109,8 @@
const event_name = '{{ event_name }}' const event_name = '{{ event_name }}'
const event_info = '{{ event_info | tojson }}' const event_info = '{{ event_info | tojson }}'
const event_banner = JSON.parse('{{ event_banner | tojson | safe }}') const event_banner = JSON.parse('{{ event_banner | tojson | safe }}')
const event_extra = JSON.parse('{{ event_extra | safe }}')
const has_promoCodes = {{ has_promo_codes | tojson }}
</script> </script>
<script src="{{ static_url_for('events/static', path='js/display.js') }}"></script> <script src="{{ static_url_for('events/static', path='js/display.js') }}"></script>
{% endblock %} {% endblock %}

View file

@ -4,7 +4,7 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md"> <div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card> <q-card>
<q-card-section> <q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true" <q-btn unelevated color="primary" @click="openEventDialog"
>New Event</q-btn >New Event</q-btn
> >
</q-card-section> </q-card-section>
@ -33,7 +33,7 @@
<template v-slot:header="props"> <template v-slot:header="props">
<q-tr :props="props"> <q-tr :props="props">
<q-th auto-width></q-th> <q-th auto-width></q-th>
<q-th auto-width></q-th>
<q-th v-for="col in props.cols" :key="col.name" :props="props"> <q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-text="col.label"></span> <span v-text="col.label"></span>
</q-th> </q-th>
@ -43,6 +43,16 @@
</template> </template>
<template v-slot:body="props"> <template v-slot:body="props">
<q-tr :props="props"> <q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.expand = !props.expand"
:icon="props.expand ? 'expand_less' : 'expand_more'"
/>
</q-td>
<q-td auto-width> <q-td auto-width>
<q-btn <q-btn
unelevated unelevated
@ -89,6 +99,52 @@
></q-btn> ></q-btn>
</q-td> </q-td>
</q-tr> </q-tr>
<q-tr v-show="props.expand" :props="props">
<q-td colspan="100%">
<div class="q-pa-md">
<div class="text-subtitle1 q-mb-md">Promo codes</div>
<div class="column">
<div
v-if="props.row.extra.promo_codes.length == 0"
class="text-caption"
>
No promo codes for this event.
</div>
<div
v-for="(code, index) in props.row.extra.promo_codes"
:key="index"
class="row items-center q-col-gutter-sm q-mb-sm"
>
<div class="col-auto">
<q-chip
square
size="md"
clickable
@click="utils.copyText(code.code.toUpperCase())"
>
<q-avatar
icon="bookmark"
:color="code.active ? 'green' : 'grey'"
text-color="white"
></q-avatar>
<span v-text="code.code.toUpperCase()"></span>
</q-chip>
</div>
<div class="col-auto">
Discount: <span v-text="code.discount_percent"></span>%
</div>
<div class="col-auto">
Status:
<span
:class="code.active ? 'text-green' : 'text-grey'"
v-text="code.active ? 'Active' : 'Inactive'"
></span>
</div>
</div>
</div>
</div>
</q-td>
</q-tr>
</template> </template>
</q-table> </q-table>
</q-card-section> </q-card-section>
@ -224,7 +280,6 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-4">Event begins</div> <div class="col-4">Event begins</div>
<div class="col-8"> <div class="col-8">
@ -248,7 +303,6 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col"> <div class="col">
<q-select <q-select
@ -280,9 +334,101 @@
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'" :mask="formDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0" fill-mask="0"
reverse-fill-mask reverse-fill-mask
:disable="formDialog.data.currency == null"
></q-input> ></q-input>
</div> </div>
</div> </div>
<q-expansion-item
group="advanced"
icon="settings"
label="Advanced options"
>
<div class="row q-mt-lg">
<div class="text-subtitle1 q-mb-md">Conditional Events</div>
<div class="text-caption">
Make this event conditional if
<strong>minimum tickets</strong> are sold. User will be asked to
provide a Lightning Address or LNURL pay for refunds.
</div>
<div class="col-8">
<q-toggle
v-model="formDialog.data.extra.conditional"
label="Conditional Event"
left-label
></q-toggle>
</div>
<div class="col-4">
<q-input
filled
dense
v-model.number="formDialog.data.extra.min_tickets"
type="number"
label="Minimum Tickets"
:disable="!formDialog.data.extra.conditional"
></q-input>
</div>
</div>
<q-separator class="q-my-md"></q-separator>
<div class="text-subtitle1 q-mb-md">Promo Codes</div>
<div class="text-caption">
Allow users to enter a promo code for discounts.
</div>
<div
v-for="(code, index) in formDialog.data.extra.promo_codes"
:key="index"
class="row q-col-gutter-sm q-mt-md"
>
<q-input
class="col-8"
filled
dense
v-model.trim="formDialog.data.extra.promo_codes[index].code"
type="text"
label="Promo Code"
>
<template v-slot:before>
<q-checkbox
left-label
v-model="formDialog.data.extra.promo_codes[index].active"
checked-icon="radio_button_checked"
unchecked-icon="radio_button_unchecked"
></q-checkbox>
<q-tooltip>
<span
v-text="formDialog.data.extra.promo_codes[index].active ? 'Active' : 'Inactive'"
></span>
</q-tooltip>
</template>
</q-input>
<q-input
class="col-4"
filled
dense
v-model.number="formDialog.data.extra.promo_codes[index].discount_percent"
type="number"
label="Discount (%)"
min="0"
max="100"
>
<template v-slot:after>
<q-btn
round
dense
flat
icon="delete"
@click="formDialog.data.extra.promo_codes.splice(index, 1)"
></q-btn>
</template>
</q-input>
</div>
<div class="col-12 q-mt-md">
<q-btn
@click="formDialog.data.extra.promo_codes.push({code: '', discount_percent: 0, active: true})"
>Add Promo Code</q-btn
>
</div>
</q-expansion-item>
<div class="row q-mt-lg"> <div class="row q-mt-lg">
<q-btn <q-btn

View file

@ -8,7 +8,8 @@ from lnbits.helpers import template_renderer
from starlette.exceptions import HTTPException from starlette.exceptions import HTTPException
from starlette.responses import HTMLResponse 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() events_generic_router = APIRouter()
@ -32,6 +33,15 @@ async def display(request: Request, event_id):
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist." 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()
)
is_min_tickets_met = (
event.sold >= event.extra.min_tickets if event.extra.conditional else True
)
if event.amount_tickets < 1: if event.amount_tickets < 1:
return events_renderer().TemplateResponse( return events_renderer().TemplateResponse(
"events/error.html", "events/error.html",
@ -41,8 +51,20 @@ async def display(request: Request, event_id):
"event_error": "Sorry, tickets are sold out :(", "event_error": "Sorry, tickets are sold out :(",
}, },
) )
datetime_object = datetime.strptime(event.closing_date, "%Y-%m-%d").date() if event.extra.conditional and not is_min_tickets_met and not is_window_open:
if date.today() > datetime_object: 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, event was cancelled.",
},
)
if not is_window_open:
return events_renderer().TemplateResponse( return events_renderer().TemplateResponse(
"events/error.html", "events/error.html",
{ {
@ -52,6 +74,12 @@ async def display(request: Request, event_id):
}, },
) )
if len(event.extra.promo_codes) > 0:
has_promo_codes = True
else:
has_promo_codes = False
event.extra.promo_codes = []
return events_renderer().TemplateResponse( return events_renderer().TemplateResponse(
"events/display.html", "events/display.html",
{ {
@ -61,6 +89,8 @@ async def display(request: Request, event_id):
"event_info": event.info, "event_info": event.info,
"event_price": event.price_per_ticket, "event_price": event.price_per_ticket,
"event_banner": event.banner, "event_banner": event.banner,
"event_extra": event.extra.json(),
"has_promo_codes": has_promo_codes,
}, },
) )

View file

@ -26,12 +26,11 @@ from .crud import (
get_events, get_events,
get_ticket, get_ticket,
get_tickets, get_tickets,
purge_unpaid_tickets,
update_event, update_event,
update_ticket, update_ticket,
) )
from .models import CreateEvent, CreateTicket, 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() events_api_router = APIRouter()
@ -77,6 +76,26 @@ async def api_event_create(
return event.dict() 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}") @events_api_router.delete("/api/v1/events/{event_id}")
async def api_form_delete( async def api_form_delete(
event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key) event_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
@ -116,11 +135,15 @@ async def api_tickets(
async def api_ticket_create(event_id: str, data: CreateTicket): async def api_ticket_create(event_id: str, data: CreateTicket):
name = data.name name = data.name
email = data.email email = data.email
return await api_ticket_make_ticket(event_id, name, email) 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
)
@events_api_router.get("/api/v1/tickets/{event_id}/{name}/{email}") @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) event = await get_event(event_id)
if not event: if not event:
raise HTTPException( raise HTTPException(
@ -130,14 +153,25 @@ async def api_ticket_make_ticket(event_id, name, email):
price = event.price_per_ticket price = event.price_per_ticket
extra = {"tag": "events", "name": name, "email": email} extra = {"tag": "events", "name": name, "email": email}
if event.currency != "sats": if promo_code:
price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) # 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["fiat"] = True
extra["currency"] = event.currency extra["currency"] = event.currency
extra["fiatAmount"] = event.price_per_ticket extra["fiatAmount"] = price
extra["rate"] = await get_fiat_rate_satoshis(event.currency) extra["rate"] = await get_fiat_rate_satoshis(event.currency)
price = await fiat_amount_as_satoshis(price, event.currency)
try: try:
payment = await create_invoice( payment = await create_invoice(
wallet_id=event.wallet, wallet_id=event.wallet,
@ -151,6 +185,11 @@ async def api_ticket_make_ticket(event_id, name, email):
event=event.id, event=event.id,
name=name, name=name,
email=email, email=email,
extra={
"applied_promo_code": promo_code,
"refund_address": refund_address,
"sats_paid": int(price),
},
) )
except Exception as exc: except Exception as exc:
raise HTTPException( raise HTTPException(
@ -176,16 +215,31 @@ async def api_ticket_send_ticket(event_id, payment_hash):
) )
payment = await get_standalone_payment(payment_hash, incoming=True) payment = await get_standalone_payment(payment_hash, incoming=True)
assert payment 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 = ( price = (
event.price_per_ticket * 1000 event.price_per_ticket * 1000
if event.currency == "sats" if event.currency == "sats"
else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency) else await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
* 1000 * 1000
) )
# check if price is equal to payment.amount # check if price is equal to payment.amount
lower_bound = price * 0.99 # 1% decrease lower_bound = price * 0.99 # 1% decrease
if not payment.pending and abs(payment.amount) >= lower_bound: # allow 1% error 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) await set_ticket_paid(ticket)
return {"paid": True, "ticket_id": ticket.id} return {"paid": True, "ticket_id": ticket.id}
@ -208,17 +262,6 @@ async def api_ticket_delete(
await delete_ticket(ticket_id) 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}") @events_api_router.get("/api/v1/eventtickets/{event_id}")
async def api_event_tickets(event_id: str) -> list[Ticket]: async def api_event_tickets(event_id: str) -> list[Ticket]:
return await get_event_tickets(event_id) return await get_event_tickets(event_id)