Compare commits

...

6 commits

Author SHA1 Message Date
Patrick Mulligan
2740d73678 feat: add user_id ticket support and public events endpoint
Some checks failed
lint / lint (push) Has been cancelled
- Tickets can be created with user_id instead of name/email
- name/email default to empty string in DB (not NULL-safe)
- New endpoints: GET /api/v1/events/public, GET /api/v1/tickets/user/{user_id}
- POST /api/v1/tickets/{event_id} accepts user_id in body
- Migration m007 adds user_id column to tickets table
- CreateTicket validates: either user_id or (name + email) required

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 02:55:18 -04:00
dni ⚡
f06bd9a668
chore: prepare release, fix lint and uv warnings (#44)
Some checks failed
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
2026-04-15 17:37:34 +02:00
PatMulligan
78433a7d85
Fix: SQLite migration syntax error in m006 (#41)
Ran into this issue on my lnbits 1.4 on NixOS using the flake

- Fix m006_add_extra_fields migration that fails on SQLite with syntax error
- Split multi-column ALTER TABLE into separate statements (SQLite doesn't support adding multiple columns in one statement)
2026-04-15 17:30:34 +02:00
DoktorShift
1dd6f8b67e
docs: changes to more pages (#42)
* Changes to more pages
* Update description.md

---------

Co-authored-by: dni  <office@dnilabs.com>
2026-01-28 17:22:09 +01:00
Tiago Vasconcelos
42de6d4791
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>
2025-12-09 10:48:00 +00:00
arbadacarba
ee70c300f6
Fix typos (#39) 2025-12-09 10:28:48 +01:00
15 changed files with 1742 additions and 1145 deletions

View file

@ -1,10 +1,20 @@
<a href="https://lnbits.com" target="_blank" rel="noopener noreferrer">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://i.imgur.com/QE6SIrs.png">
<img src="https://i.imgur.com/fyKPgVT.png" alt="LNbits" style="width:280px">
</picture>
</a>
[![License: MIT](https://img.shields.io/badge/License-MIT-success?logo=open-source-initiative&logoColor=white)](./LICENSE)
[![Built for LNbits](https://img.shields.io/badge/Built%20for-LNbits-4D4DFF?logo=lightning&logoColor=white)](https://github.com/lnbits/lnbits)
# Events - <small>[LNbits](https://github.com/lnbits/lnbits) extension</small>
<small>For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)</small>
## Sell tickets for events and use the built-in scanner for registering attendants
## Sell tickets for events and use the built-in scanner for registering attendees
Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance.
Events allows you to create tickets for an event. Each ticket is in the form of a unique QR code. After registering and paying, the user gets a QR code to present at registration/entrance.
Events includes a shareable ticket scanner, which can be used to register attendees.
@ -33,3 +43,10 @@ Events includes a shareable ticket scanner, which can be used to register attend
4. Use the built-in ticket scanner to validate registered, and paid, attendees\
![ticket scanner](https://i.imgur.com/zrm9202.jpg)
## Powered by LNbits
[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
[![Visit LNbits Shop](https://img.shields.io/badge/Visit-LNbits%20Shop-7C3AED?logo=shopping-cart&logoColor=white&labelColor=5B21B6)](https://shop.lnbits.com/)
[![Try myLNbits SaaS](https://img.shields.io/badge/Try-myLNbits%20SaaS-2563EB?logo=lightning&logoColor=white&labelColor=1E40AF)](https://my.lnbits.com/login)

View file

@ -1,8 +1,11 @@
{
"id": "events",
"version": "1.2.1",
"name": "Events",
"repo": "https://github.com/lnbits/events",
"short_description": "Sell and register event tickets",
"description": "",
"tile": "/events/static/image/events.png",
"lnbits": "1.1.0",
"min_lnbits_version": "1.3.0",
"contributors": [
{
@ -51,5 +54,9 @@
],
"description_md": "https://raw.githubusercontent.com/lnbits/events/main/description.md",
"terms_and_conditions_md": "https://raw.githubusercontent.com/lnbits/events/main/toc.md",
"license": "MIT"
"license": "MIT",
"paid_features": "",
"tags": ["Fun & Social", "Ticketing"],
"donate": "",
"hidden": false
}

30
crud.py
View file

@ -1,15 +1,22 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
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 = "",
user_id: Optional[str] = None,
extra: Optional[dict] = None,
) -> Ticket:
now = datetime.now(timezone.utc)
ticket = Ticket(
@ -18,10 +25,12 @@ async def create_ticket(
event=event,
name=name,
email=email,
user_id=user_id,
registered=False,
paid=False,
reg_timestamp=now,
time=now,
extra=TicketExtra(**extra) if extra else TicketExtra(),
)
await db.insert("events.ticket", ticket)
return ticket
@ -101,6 +110,23 @@ async def get_events(wallet_ids: str | list[str]) -> list[Event]:
)
async def get_all_events() -> list[Event]:
"""Get all events without wallet filtering (public endpoint)."""
return await db.fetchall(
"SELECT * FROM events.events ORDER BY time DESC",
model=Event,
)
async def get_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""Get all tickets for a specific user by their user_id."""
return await db.fetchall(
"SELECT * FROM events.ticket WHERE user_id = :user_id ORDER BY time DESC",
{"user_id": user_id},
model=Ticket,
)
async def delete_event(event_id: str) -> None:
await db.execute("DELETE FROM events.events WHERE id = :id", {"id": event_id})

View file

@ -1,5 +1,10 @@
Sell tickets for events and use the built-in scanner for registering attendants
Sell tickets for events and manage attendee registration with a built-in QR scanner.
Events alows you to make tickets for an event. Each ticket is in the form of a uniqque QR code. After registering, and paying for ticket, the user gets a QR code to present at registration/entrance.
Its features include:
Events includes a shareable ticket scanner, which can be used to register attendees.
- Creating events with ticket pricing
- Generating unique QR code tickets after payment
- Providing a shareable ticket scanner for check-in
- Tracking registered and checked-in attendees
A complete ticketing solution for event organizers, meetup hosts, and conference planners who want to sell tickets and manage attendance with Bitcoin.

View file

@ -160,3 +160,26 @@ 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 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;"
)
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;")
async def m007_add_user_id(db):
"""
Add user_id column to tickets table.
Allows ticket purchase via LNbits user-id without name/email.
"""
await db.execute("ALTER TABLE events.ticket ADD COLUMN user_id TEXT;")

View file

@ -1,7 +1,30 @@
from datetime import datetime
from typing import Optional
from fastapi import Query
from pydantic import BaseModel, EmailStr
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):
@ -15,11 +38,7 @@ class CreateEvent(BaseModel):
amount_tickets: int = Query(..., ge=0)
price_per_ticket: float = Query(..., ge=0)
banner: str | None = None
class CreateTicket(BaseModel):
name: str
email: EmailStr
extra: EventExtra = Field(default_factory=EventExtra)
class Event(BaseModel):
@ -28,6 +47,7 @@ class Event(BaseModel):
name: str
info: str
closing_date: str
canceled: bool = False
event_start_date: str
event_end_date: str
currency: str
@ -36,15 +56,42 @@ 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
sats_paid: int | None = None
refund_address: str | None = None
refunded: bool = False
class CreateTicket(BaseModel):
name: Optional[str] = None
email: Optional[str] = None
user_id: Optional[str] = None
promo_code: str | None = None
refund_address: str | None = None
@root_validator
def validate_identifiers(cls, values):
user_id = values.get("user_id")
name = values.get("name")
email = values.get("email")
if not user_id and not (name and email):
raise ValueError("Either user_id or both name and email must be provided")
return values
class Ticket(BaseModel):
id: str
wallet: str
event: str
name: str
email: str
name: str = ""
email: str = ""
user_id: Optional[str] = None
registered: bool
paid: bool
time: datetime
reg_timestamp: datetime
extra: TicketExtra = Field(default_factory=TicketExtra)

View file

@ -7,11 +7,8 @@ authors = [{ name = "Alan Bits", email = "alan@lnbits.com" }]
urls = { Homepage = "https://lnbits.com", Repository = "https://github.com/lnbits/events" }
dependencies = [ "lnbits>1" ]
[tool.poetry]
package-mode = false
[tool.uv]
dev-dependencies = [
[dependency-groups]
dev = [
"black",
"pytest-asyncio",
"pytest",
@ -20,6 +17,9 @@ dev-dependencies = [
"ruff",
]
[tool.poetry]
package-mode = false
[tool.mypy]
exclude = "(nostr/*)"
plugins = ["pydantic.mypy"]

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
@ -16,3 +25,30 @@ 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.
"""
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,
data: {
name: '',
email: ''
email: '',
refund: ''
}
},
ticketLink: {
@ -29,7 +30,8 @@ window.app = Vue.createApp({
this.info = event_info
this.info = this.info.substring(1, this.info.length - 1)
this.banner = event_banner
await this.purgeUnpaidTickets()
this.extra = event_extra
this.hasPromoCodes = has_promoCodes
},
computed: {
formatDescription() {
@ -41,6 +43,7 @@ window.app = Vue.createApp({
e.preventDefault()
this.formDialog.data.name = ''
this.formDialog.data.email = ''
this.formDialog.data.refund = ''
},
closeReceiveDialog() {
@ -60,12 +63,12 @@ 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}`, {
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
@ -122,13 +125,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)
}
}
}
})

View file

@ -1,9 +1,6 @@
const mapEvents = function (obj) {
obj.date = Quasar.date.formatDate(
new Date(obj.time * 1000),
'YYYY-MM-DD HH:mm'
)
obj.fsat = new Intl.NumberFormat(LOCALE).format(obj.price_per_ticket)
obj.date = LNbits.utils.formatTimestamp(obj.time)
obj.fsat = new Intl.NumberFormat(window.g.locale).format(obj.price_per_ticket)
obj.displayUrl = ['/events/', obj.id].join('')
return obj
}
@ -20,8 +17,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',
@ -40,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',
@ -65,7 +71,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
@ -73,7 +81,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'},
@ -82,7 +89,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
@ -90,7 +104,11 @@ window.app = Vue.createApp({
},
formDialog: {
show: false,
data: {}
data: {
extra: {
promo_codes: []
}
}
}
}
},
@ -143,9 +161,10 @@ window.app = Vue.createApp({
this.g.user.wallets[0].inkey
)
.then(response => {
this.events = response.data.map(function (obj) {
this.events = response.data.map(obj => {
return mapEvents(obj)
})
this.checkCanceledEvents()
})
},
sendEventData() {
@ -153,6 +172,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)
@ -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) {
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.resetEventDialog()
})
.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 +234,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)
},
@ -216,6 +260,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() {

View file

@ -6,7 +6,7 @@
<q-card-section class="q-pa-none">
<h3 class="q-my-none q-pa-lg">{{ event_name }}</h3>
<br />
<div v-html="formatDescription"></div>
<div v-html="formatDescription" class="q-pa-md"></div>
<br />
</q-card-section>
</q-card>
@ -30,7 +30,23 @@
:rules="[val => !!val || '* Required', val => emailValidation(val)]"
lazy-rules
></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">
<q-btn
unelevated
@ -93,6 +109,8 @@
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 }}')
const has_promoCodes = {{ has_promo_codes | tojson }}
</script>
<script src="{{ static_url_for('events/static', path='js/display.js') }}"></script>
{% endblock %}

View file

@ -4,7 +4,7 @@
<div class="col-12 col-md-8 col-lg-7 q-gutter-y-md">
<q-card>
<q-card-section>
<q-btn unelevated color="primary" @click="formDialog.show = true"
<q-btn unelevated color="primary" @click="openEventDialog"
>New Event</q-btn
>
</q-card-section>
@ -33,7 +33,7 @@
<template v-slot:header="props">
<q-tr :props="props">
<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">
<span v-text="col.label"></span>
</q-th>
@ -43,6 +43,16 @@
</template>
<template v-slot:body="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-btn
unelevated
@ -89,6 +99,52 @@
></q-btn>
</q-td>
</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>
</q-table>
</q-card-section>
@ -224,7 +280,6 @@
></q-input>
</div>
</div>
<div class="row">
<div class="col-4">Event begins</div>
<div class="col-8">
@ -248,7 +303,6 @@
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm">
<div class="col">
<q-select
@ -280,9 +334,101 @@
:mask="formDialog.data.currency != 'sats' ? '#.##' : '#'"
fill-mask="0"
reverse-fill-mask
:disable="formDialog.data.currency == null"
></q-input>
</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">
<q-btn

2144
uv.lock generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,8 @@ 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()
@ -32,6 +33,15 @@ 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()
)
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 +51,20 @@ 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 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, event was cancelled.",
},
)
if not is_window_open:
return events_renderer().TemplateResponse(
"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(
"events/display.html",
{
@ -61,6 +89,8 @@ 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(),
"has_promo_codes": has_promo_codes,
},
)

View file

@ -21,17 +21,18 @@ from .crud import (
delete_event,
delete_event_tickets,
delete_ticket,
get_all_events,
get_event,
get_event_tickets,
get_events,
get_ticket,
get_tickets,
purge_unpaid_tickets,
get_tickets_by_user_id,
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()
@ -50,6 +51,12 @@ async def api_events(
return [event.dict() for event in await get_events(wallet_ids)]
@events_api_router.get("/api/v1/events/public")
async def api_events_public():
"""Retrieve all events (read-only, no auth required)."""
return [event.dict() for event in await get_all_events()]
@events_api_router.post("/api/v1/events")
@events_api_router.put("/api/v1/events/{event_id}")
async def api_event_create(
@ -77,6 +84,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)
@ -112,15 +139,25 @@ async def api_tickets(
return await get_tickets(wallet_ids)
@events_api_router.get("/api/v1/tickets/user/{user_id}")
async def api_tickets_by_user_id(user_id: str) -> list[Ticket]:
"""Get all tickets for a specific user by their user_id."""
return await get_tickets_by_user_id(user_id)
@events_api_router.post("/api/v1/tickets/{event_id}")
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)
if data.user_id:
return await api_ticket_make_ticket_with_user_id(event_id, data.user_id)
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
)
@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 +167,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 +199,48 @@ 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(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(exc)
) from exc
return {"payment_hash": payment.payment_hash, "payment_request": payment.bolt11}
async def api_ticket_make_ticket_with_user_id(event_id: str, user_id: str):
event = await get_event(event_id)
if not event:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND, detail="Event does not exist."
)
price = event.price_per_ticket
extra = {"tag": "events", "user_id": user_id}
if event.currency != "sats":
extra["fiat"] = True
extra["currency"] = event.currency
extra["fiatAmount"] = event.price_per_ticket
extra["rate"] = await get_fiat_rate_satoshis(event.currency)
price = await fiat_amount_as_satoshis(event.price_per_ticket, event.currency)
try:
payment = await create_invoice(
wallet_id=event.wallet,
amount=price,
memo=f"{event_id}",
extra=extra,
)
await create_ticket(
payment_hash=payment.payment_hash,
wallet=event.wallet,
event=event.id,
user_id=user_id,
)
except Exception as exc:
raise HTTPException(
@ -176,16 +266,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}
@ -208,17 +313,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)