Compare commits

..

3 commits

Author SHA1 Message Date
c9fbd8335c docs: promo-code contract; bump 1.6.1-aio.12
Some checks failed
lint.yml / docs: promo-code contract; bump 1.6.1-aio.12 (pull_request) Failing after 0s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:20:42 +02:00
dd04b655b8 feat(admin): max uses column + used count in the promo editor
The Quasar editor gains a "Max uses" input per code (blank = unlimited,
normalised to null on save) and shows "Used n / max" from the derived
count. The public buy page's Clear button now also clears the promo field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:20:42 +02:00
a386123d55 feat(promo): enforce active + max_uses, validate endpoint, codes hidden from public
Promo handling was inherited from upstream unchanged and had four gaps
the webapp was about to put in front of buyers:

- `active` was decorative: purchase never read it, so a deactivated code
  kept discounting. Now rejected with "Promo code is not active."
- No redemption cap (#32). `PromoCode.max_uses` (None/0 = unlimited) with
  `used_count` DERIVED from paid tickets carrying the code in
  `extra.applied_promo_code` — each ticket of a multi-ticket purchase
  consumes one use (upstream v2 counts one per basket; documented).
  Paid-only counting so an abandoned Stripe session can't lock out the
  last uses for the 24 h unpaid-row lifetime; bounded overshoot under
  concurrency accepted.
- Every code was readable by anyone: `PublicEvent.extra` was the full
  `EventExtra` and `/events/public` returned the untrimmed `Event`
  (wallet id included). `EventExtraBase` / `PublicEventExtra` project
  them out; `/public` now goes through `PublicEvent`. Organizer and admin
  listings keep the full model, now hydrated with `used_count`.
- No preview: `POST /events/api/v1/promo/validate/{event_id}` (same URL as
  upstream v2; `quantity` replaces v2's `items` since this fork has no
  ticket types) returns v2-shaped `BasketTotals` + `currency`. Advisory:
  bad codes are simply absent from `discounts_applied`; purchase still
  hard-fails them with distinct details.

All pricing (validate, invoice, Stripe amount) goes through one pure
`basket_totals` with a single rounding rule (whole sats / 2 dp fiat), so
the preview equals the charge. Stripe metadata carries `promo_code`; the
organizer stats rows carry `applied_promo_code`.

`api_event_update` keeps stored codes when the request omits
`extra.promo_codes` (explicit `[]` still clears): now that public
records don't carry them, a client round-tripping one would otherwise
wipe the organizer's codes on every edit.

Closes #32

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
2026-09-10 12:20:42 +02:00
3 changed files with 59 additions and 235 deletions

View file

@ -1,6 +1,6 @@
{ {
"id": "events", "id": "events",
"version": "1.6.1-aio.14", "version": "1.6.1-aio.12",
"name": "Events", "name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events", "repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets", "short_description": "Sell and register event tickets",

View file

@ -67,15 +67,13 @@ window.PageEvents = {
name: 'event_start_date', name: 'event_start_date',
align: 'left', align: 'left',
label: 'Start date', label: 'Start date',
field: 'event_start_date', field: 'event_start_date'
format: val => this.formatEventDate(val)
}, },
{ {
name: 'event_end_date', name: 'event_end_date',
align: 'left', align: 'left',
label: 'End date', label: 'End date',
field: 'event_end_date', field: 'event_end_date'
format: val => this.formatEventDate(val)
}, },
{ {
name: 'closing_date', name: 'closing_date',
@ -133,19 +131,6 @@ window.PageEvents = {
{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'},
{
name: 'email_sent',
align: 'left',
label: 'Email sent',
field: row =>
!row.email
? 'no email'
: row.extra?.email_notification_sent
? '\u2713 sent'
: row.paid
? 'not sent'
: 'unpaid'
},
{ {
name: 'registered', name: 'registered',
align: 'left', align: 'left',
@ -164,58 +149,12 @@ window.PageEvents = {
rowsPerPage: 10 rowsPerPage: 10
} }
}, },
// Rails an organizer can enable per event. Mirrors the webapp's
// CreateEventDialog; `fiat` is rendered disabled when the LNbits user
// has no fiat provider (see `hasFiatProvider`).
paymentMethodOptions: [
{
value: 'lightning',
label: 'Lightning',
hint: 'Pay with any Lightning wallet'
},
{
value: 'fiat',
label: 'Card',
hint: 'Card or bank through your configured fiat provider'
}
],
// Same list the webapp offers (src/modules/events/types/category.ts);
// published as NIP-52 `t` tags so both clients filter on one vocabulary.
categoryOptions: [
'concert',
'workshop',
'market',
'festival',
'exhibition',
'sport',
'theater',
'cinema',
'party',
'talk',
'conference',
'meetup',
'food',
'outdoor',
'kids',
'wellness',
'technology',
'art',
'music',
'dance',
'literature',
'comedy',
'charity',
'tradition',
'other'
].map(c => ({label: c.charAt(0).toUpperCase() + c.slice(1), value: c})),
formDialog: { formDialog: {
show: false, show: false,
data: { data: {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
extra: { extra: {
payment_methods: ['lightning'], payment_methods: ['lightning'],
promo_codes: [], promo_codes: [],
@ -226,27 +165,6 @@ window.PageEvents = {
} }
} }
}, },
computed: {
hasFiatProvider() {
return (this.g.user?.fiat_providers || []).length > 0
},
fiatProviderNames() {
return (this.g.user?.fiat_providers || [])
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
.join(', ')
},
acceptsFiat() {
return (this.formDialog.data.extra?.payment_methods || []).includes(
'fiat'
)
},
isSatPrice() {
return !this.isFiatCurrency(this.formDialog.data.currency)
},
fiatCurrencyOptions() {
return this.currencies.filter(c => this.isFiatCurrency(c))
}
},
methods: { methods: {
isFiatCurrency(currency) { isFiatCurrency(currency) {
return !['sat', 'sats'].includes((currency || '').toLowerCase()) return !['sat', 'sats'].includes((currency || '').toLowerCase())
@ -464,62 +382,29 @@ window.PageEvents = {
}, },
splitDateTime(value) { splitDateTime(value) {
// Inverse of foldDateTime: split a stored string back into the // Inverse of foldDateTime: split a stored string back into the
// day/time pieces the form inputs bind to. Slicing to HH:MM also // day/time pieces the form inputs bind to.
// drops the seconds + offset suffix withLocalTzOffset stamps on
// submit, so the organizer sees the wall-clock they entered.
if (!value) return {day: '', time: ''} if (!value) return {day: '', time: ''}
const [day, time = ''] = value.split('T') const [day, time = ''] = value.split('T')
// Time inputs only accept HH:MM, drop any seconds we stored. // Time inputs only accept HH:MM, drop any seconds we stored.
return {day, time: time.slice(0, 5)} return {day, time: time.slice(0, 5)}
}, },
withLocalTzOffset(value) {
// Stamp the browser's UTC offset on a "YYYY-MM-DDTHH:MM" value.
// The publisher's `_to_unix` treats a naive datetime as UTC, so an
// event entered as 18:00 in CEST would otherwise go out on Nostr
// as 18:00 UTC. Same transform the webapp applies; date-only
// values pass through unchanged (they map to NIP-52 kind 31922).
if (!value || !value.includes('T')) return value
const offMin = -new Date(value).getTimezoneOffset()
const sign = offMin >= 0 ? '+' : '-'
const abs = Math.abs(offMin)
const hh = String(Math.floor(abs / 60)).padStart(2, '0')
const mm = String(abs % 60).padStart(2, '0')
return `${value}:00${sign}${hh}:${mm}`
},
formatEventDate(value) {
// Table display: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM".
if (!value) return ''
const {day, time} = this.splitDateTime(value)
return time ? `${day} ${time}` : day
},
validateEndDate() {
// Cross-field rule for the end-day input: end >= start, compared
// on the folded date+time so an equal-day earlier time is caught.
const d = this.formDialog.data
const start = this.foldDateTime(d.event_start_day, d.event_start_time)
const end = this.foldDateTime(d.event_end_day, d.event_end_time)
if (!start || !end) return true
return end >= start || 'End must be on or after start'
},
sendEventData() { sendEventData() {
const wallet = _.findWhere(this.g.user.wallets, { const wallet = _.findWhere(this.g.user.wallets, {
id: this.formDialog.data.wallet id: this.formDialog.data.wallet
}) })
const data = {...this.formDialog.data} const data = {...this.formDialog.data}
data.event_start_date = this.withLocalTzOffset( data.event_start_date = this.foldDateTime(
this.foldDateTime(data.event_start_day, data.event_start_time) data.event_start_day,
data.event_start_time
) )
data.event_end_date = this.withLocalTzOffset( data.event_end_date = this.foldDateTime(
this.foldDateTime(data.event_end_day, data.event_end_time) data.event_end_day,
data.event_end_time
) )
delete data.event_start_day delete data.event_start_day
delete data.event_start_time delete data.event_start_time
delete data.event_end_day delete data.event_end_day
delete data.event_end_time delete data.event_end_time
// Optional NIP-52 fields: blank location is "unset", not "".
data.location = (data.location || '').trim() || null
data.categories = data.categories || []
data.closing_date = data.closing_date || null
if (data.extra?.promo_codes) { if (data.extra?.promo_codes) {
data.extra.promo_codes = data.extra.promo_codes data.extra.promo_codes = data.extra.promo_codes
@ -542,13 +427,11 @@ window.PageEvents = {
// allow_fiat stays the fiat-currency carrier the backend and the // allow_fiat stays the fiat-currency carrier the backend and the
// NIP-52 tags read; keep it in lockstep with the checkbox list. // NIP-52 tags read; keep it in lockstep with the checkbox list.
data.allow_fiat = methods.includes('fiat') data.allow_fiat = methods.includes('fiat')
if (this.isFiatCurrency(data.currency)) { if (!this.isFiatCurrency(data.currency)) {
// A fiat-priced event settles in its price currency; mirror it so if (!data.allow_fiat) {
// the payload (and the tickets_fiat_currency tag) stay coherent.
data.fiat_currency = data.currency
} else if (!data.allow_fiat) {
data.fiat_currency = 'GBP' data.fiat_currency = 'GBP'
} }
}
if (data.id) { if (data.id) {
this.updateEvent(wallet, data) this.updateEvent(wallet, data)
@ -572,8 +455,6 @@ window.PageEvents = {
? data.extra.payment_methods ? data.extra.payment_methods
: ['lightning', ...(data.allow_fiat ? ['fiat'] : [])] : ['lightning', ...(data.allow_fiat ? ['fiat'] : [])]
}, },
location: data.location || '',
categories: [...(data.categories || [])],
event_start_day: start.day, event_start_day: start.day,
event_start_time: start.time, event_start_time: start.time,
event_end_day: end.day, event_end_day: end.day,
@ -584,8 +465,6 @@ window.PageEvents = {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
event_start_day: '', event_start_day: '',
event_start_time: '', event_start_time: '',
event_end_day: '', event_end_day: '',
@ -610,8 +489,6 @@ window.PageEvents = {
currency: 'sats', currency: 'sats',
allow_fiat: false, allow_fiat: false,
fiat_currency: 'GBP', fiat_currency: 'GBP',
location: '',
categories: [],
extra: { extra: {
payment_methods: ['lightning'], payment_methods: ['lightning'],
email_notifications: false, email_notifications: false,

View file

@ -479,10 +479,8 @@
filled filled
dense dense
v-model.trim="formDialog.data.name" v-model.trim="formDialog.data.name"
type="text" type="name"
label="Title of event *" label="Title of event "
lazy-rules
:rules="[val => !!val || 'Title is required']"
></q-input> ></q-input>
</div> </div>
<div class="col q-pl-sm"> <div class="col q-pl-sm">
@ -506,30 +504,6 @@
label="Info about the event" label="Info about the event"
hint="Markdown supported" hint="Markdown supported"
></q-input> ></q-input>
<q-input
filled
dense
v-model.trim="formDialog.data.location"
type="text"
label="Location"
hint="Venue or address, e.g. Salle des fêtes, Foix. Published as the NIP-52 location tag."
>
<template v-slot:prepend>
<q-icon name="place"></q-icon>
</template>
</q-input>
<q-select
filled
dense
multiple
use-chips
emit-value
map-options
v-model="formDialog.data.categories"
:options="categoryOptions"
label="Categories"
hint="Published as NIP-52 hashtags so clients can filter the feed."
></q-select>
<q-input <q-input
filled filled
dense dense
@ -538,16 +512,25 @@
label="Image URL" label="Image URL"
hint="Optional banner image to display on the event page" hint="Optional banner image to display on the event page"
></q-input> ></q-input>
<div class="row q-mt-lg">
<div class="col-4">Ticket closing date</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
></q-input>
</div>
</div>
<div class="row q-col-gutter-sm"> <div class="row q-col-gutter-sm">
<div class="col-4">Event begins *</div> <div class="col-4">Event begins</div>
<div class="col-5"> <div class="col-5">
<q-input <q-input
filled filled
dense dense
v-model.trim="formDialog.data.event_start_day" v-model.trim="formDialog.data.event_start_day"
type="date" type="date"
lazy-rules
:rules="[val => !!val || 'Start date is required']"
></q-input> ></q-input>
</div> </div>
<div class="col-3"> <div class="col-3">
@ -569,10 +552,6 @@
dense dense
v-model.trim="formDialog.data.event_end_day" v-model.trim="formDialog.data.event_end_day"
type="date" type="date"
hint="Defaults to the start date"
lazy-rules
reactive-rules
:rules="[validateEndDate]"
></q-input> ></q-input>
</div> </div>
<div class="col-3"> <div class="col-3">
@ -585,18 +564,6 @@
></q-input> ></q-input>
</div> </div>
</div> </div>
<div class="row q-col-gutter-sm">
<div class="col-4">Ticket sales close</div>
<div class="col-8">
<q-input
filled
dense
v-model.trim="formDialog.data.closing_date"
type="date"
hint="Optional. Defaults to the event end date."
></q-input>
</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
@ -604,7 +571,7 @@
dense dense
v-model="formDialog.data.currency" v-model="formDialog.data.currency"
type="text" type="text"
label="Price currency" label="Unit"
:options="currencies" :options="currencies"
></q-select> ></q-select>
</div> </div>
@ -614,9 +581,7 @@
dense dense
v-model.number="formDialog.data.amount_tickets" v-model.number="formDialog.data.amount_tickets"
type="number" type="number"
min="0"
label="Amount of tickets " label="Amount of tickets "
hint="0 = unlimited"
></q-input> ></q-input>
</div> </div>
<div class="col"> <div class="col">
@ -635,57 +600,34 @@
</div> </div>
</div> </div>
<div class="q-mt-sm"> <div class="q-mt-sm">
<div class="text-subtitle2">Payment methods *</div> <div class="text-caption text-grey-7">Payment methods *</div>
<div class="text-caption text-grey-7"> <q-option-group
Pick the rails buyers can pay with. Untick Lightning for a
card-only sale.
</div>
<div class="row q-col-gutter-md q-mt-xs">
<div
v-for="opt in paymentMethodOptions"
:key="opt.value"
class="col-12 col-sm-6"
>
<!-- The span carries the tooltip: a disabled checkbox
swallows pointer events, the wrapper still hovers. -->
<span class="inline-block full-width">
<q-checkbox
v-model="formDialog.data.extra.payment_methods" v-model="formDialog.data.extra.payment_methods"
:val="opt.value" type="checkbox"
:label="opt.label" inline
:disable="opt.value === 'fiat' && !hasFiatProvider" :options="paymentMethodOptions"
></q-checkbox> ></q-option-group>
<div class="text-caption text-grey-7 q-pl-lg"> <div class="text-caption text-grey-7">
<span v-text="opt.hint"></span> Card / fiat checkout goes through the fiat provider configured on
<span this LNbits instance. Untick Lightning for a card-only event.
v-if="opt.value === 'fiat' && hasFiatProvider"
v-text="' (' + fiatProviderNames + ')'"
></span>
</div>
<q-tooltip
v-if="opt.value === 'fiat' && !hasFiatProvider"
max-width="280px"
>
Your LNbits user has no fiat provider configured. Ask the
instance admin to enable Stripe, PayPal or Square (Admin →
Fiat providers) to accept card payments.
</q-tooltip>
</span>
</div>
</div> </div>
</div> </div>
<q-select <q-select
v-if="acceptsFiat && isSatPrice" v-if="
acceptsFiat &&
['sat', 'sats'].includes(
(formDialog.data.currency || '').toLowerCase()
)
"
filled filled
dense dense
v-model="formDialog.data.fiat_currency" v-model="formDialog.data.fiat_currency"
label="Fiat currency" label="Fiat checkout currency"
hint="Currency card buyers are charged in." :options="
:options="fiatCurrencyOptions" currencies.filter(
lazy-rules c => !['sat', 'sats'].includes((c || '').toLowerCase())
:rules="[ )
val => !!val || 'Pick a fiat currency for buyers paying by card' "
]"
></q-select> ></q-select>
<q-expansion-item <q-expansion-item
group="advanced" group="advanced"
@ -861,9 +803,14 @@
unelevated unelevated
color="primary" color="primary"
:disable=" :disable="
!formDialog.data.wallet || formDialog.data.wallet == null ||
!formDialog.data.name || formDialog.data.name == null ||
!formDialog.data.event_start_day formDialog.data.info == null ||
formDialog.data.closing_date == null ||
formDialog.data.event_start_day == null ||
formDialog.data.event_end_day == null ||
formDialog.data.amount_tickets == null ||
formDialog.data.price_per_ticket == null
" "
type="submit" type="submit"
>Create Event</q-btn >Create Event</q-btn