feat: add promo codes and conditional events (#40)

* 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 padreug
parent 44f2cb5a62
commit a9ac6dcfc1
10 changed files with 463 additions and 71 deletions

14
crud.py
View file

@ -4,7 +4,7 @@ from typing import Optional
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")
@ -15,7 +15,8 @@ async def create_ticket(
event: str, event: str,
name: Optional[str] = None, name: Optional[str] = None,
email: Optional[str] = None, email: Optional[str] = None,
user_id: Optional[str] = None user_id: Optional[str] = None,
extra: Optional[dict] = None,
) -> Ticket: ) -> Ticket:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@ -38,6 +39,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(),
) )
# Create a dict for database insertion with proper handling of constraints # Create a dict for database insertion with proper handling of constraints
@ -47,8 +49,8 @@ async def create_ticket(
await db.execute( await db.execute(
""" """
INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp) INSERT INTO events.ticket (id, wallet, event, name, email, user_id, registered, paid, time, reg_timestamp, extra)
VALUES (:id, :wallet, :event, :name, :email, :user_id, :registered, :paid, :time, :reg_timestamp) VALUES (:id, :wallet, :event, :name, :email, :user_id, :registered, :paid, :time, :reg_timestamp, :extra)
""", """,
ticket_dict ticket_dict
) )
@ -72,8 +74,8 @@ async def update_ticket(ticket: Ticket) -> Ticket:
return ticket return ticket
async def get_ticket(payment_hash: str) -> Ticket | None: async def get_ticket(payment_hash: str) -> Optional[Ticket]:
return await db.fetchone( row = await db.fetchone(
"SELECT * FROM events.ticket WHERE id = :id", "SELECT * FROM events.ticket WHERE id = :id",
{"id": payment_hash}, {"id": payment_hash},
) )

View file

@ -174,3 +174,20 @@ async def m006_add_user_id_support(db):
# the validation that either (name AND email) OR user_id is provided # the validation that either (name AND email) OR user_id is provided
# The database will continue to expect name and email as NOT NULL # The database will continue to expect name and email as NOT NULL
# but we'll insert empty strings for user_id tickets # but we'll insert empty strings for user_id tickets
async def m007_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

@ -2,7 +2,29 @@ from datetime import datetime
from typing import Optional from typing import Optional
from fastapi import Query from fastapi import Query
from pydantic import BaseModel, EmailStr, root_validator from pydantic import BaseModel, EmailStr, Field, root_validator, 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):
@ -16,12 +38,15 @@ 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): class CreateTicket(BaseModel):
name: Optional[str] = None name: Optional[str] = None
email: Optional[EmailStr] = None email: Optional[EmailStr] = None
user_id: Optional[str] = None user_id: Optional[str] = None
promo_code: Optional[str] = None
refund_address: Optional[str] = None
@root_validator @root_validator
def validate_identifiers(cls, values): def validate_identifiers(cls, values):
@ -43,6 +68,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
@ -51,6 +77,14 @@ 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 Ticket(BaseModel): class Ticket(BaseModel):
@ -64,3 +98,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

@ -32,7 +32,7 @@ from .crud import (
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()
@ -90,6 +90,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)
@ -136,7 +156,11 @@ async def api_ticket_create(event_id: str, data: CreateTicket):
if data.user_id: if data.user_id:
return await api_ticket_make_ticket_with_user_id(event_id, data.user_id) return await api_ticket_make_ticket_with_user_id(event_id, data.user_id)
else: else:
return await api_ticket_make_ticket(event_id, data.name, data.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, data.name, data.email, promo_code, refund_address
)
async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str): async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str):
@ -183,7 +207,7 @@ async def api_ticket_make_ticket_user_id(event_id: str, user_id: str):
@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(
@ -193,14 +217,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,
@ -214,6 +249,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(
@ -239,16 +279,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}
@ -271,17 +326,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)