diff --git a/README.md b/README.md
index ebd7194..15748c7 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,20 @@
+
+
+
+
+
+
+
+[](./LICENSE)
+[](https://github.com/lnbits/lnbits)
+
# Events - [LNbits](https://github.com/lnbits/lnbits) extension
For more about LNBits extension check [this tutorial](https://github.com/lnbits/lnbits/wiki/LNbits-Extensions)
-## 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\

+
+## Powered by LNbits
+
+[LNbits](https://lnbits.com) is a free and open-source lightning accounts system.
+
+[](https://shop.lnbits.com/)
+[](https://my.lnbits.com/login)
diff --git a/config.json b/config.json
index ceb5f7c..8a9a61c 100644
--- a/config.json
+++ b/config.json
@@ -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
}
diff --git a/crud.py b/crud.py
index 6d19761..6460f7e 100644
--- a/crud.py
+++ b/crud.py
@@ -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})
diff --git a/description.md b/description.md
index b47bd07..6e22c7d 100644
--- a/description.md
+++ b/description.md
@@ -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.
diff --git a/migrations.py b/migrations.py
index 87a0dd4..1d9cfe4 100644
--- a/migrations.py
+++ b/migrations.py
@@ -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;")
diff --git a/models.py b/models.py
index f0a52b2..1073806 100644
--- a/models.py
+++ b/models.py
@@ -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)
diff --git a/pyproject.toml b/pyproject.toml
index 4508802..0640a6c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"]
diff --git a/services.py b/services.py
index 1286534..9099ef0 100644
--- a/services.py
+++ b/services.py
@@ -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}")
diff --git a/static/js/display.js b/static/js/display.js
index 4884751..6098e5a 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: {
@@ -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)
- }
}
}
})
diff --git a/static/js/index.js b/static/js/index.js
index cccbe82..d26133c 100644
--- a/static/js/index.js
+++ b/static/js/index.js
@@ -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() {
diff --git a/templates/events/display.html b/templates/events/display.html
index 24a64e4..73d279d 100644
--- a/templates/events/display.html
+++ b/templates/events/display.html
@@ -6,7 +6,7 @@
{{ event_name }}
-
+
@@ -30,7 +30,23 @@
:rules="[val => !!val || '* Required', val => emailValidation(val)]"
lazy-rules
>
-
+
+
{% endblock %}
diff --git a/templates/events/index.html b/templates/events/index.html
index d9c6bb3..62752d1 100644
--- a/templates/events/index.html
+++ b/templates/events/index.html
@@ -4,7 +4,7 @@
- New Event
@@ -33,7 +33,7 @@
-
+
@@ -43,6 +43,16 @@
+
+
+
+
+
+
+
Promo codes
+
+
+ No promo codes for this event.
+
+
+
+
+
+
+
+
+
+ Discount: %
+
+
+ Status:
+
+
+
+
+
+
+
@@ -224,7 +280,6 @@
>
-
Event begins
@@ -248,7 +303,6 @@
>
-
+
+
+
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.
+
+
+
+
+
+
+
+
+
+ Promo Codes
+
+ Allow users to enter a promo code for discounts.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Add Promo Code
+
+
= 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,
},
)
diff --git a/views_api.py b/views_api.py
index db4b34f..e06bfde 100644
--- a/views_api.py
+++ b/views_api.py
@@ -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)