Compare commits

...

3 commits

Author SHA1 Message Date
c323875ec9 Merge pull request 'Ticket waves follow the event's fiat setting' (#179) from fix/wave-fiat-inheritance into dev
Reviewed-on: #179
2026-09-30 20:30:48 +00:00
773d87d35b fix(events): the Card checkbox governs fiat on every wave
Follows the inheritance commit: seeding only NEW waves still left the
ones already on an event refusing card payments, and gave the organiser
no single place to change that.

The extension treats fiat as a per-wave opt-in, but the webapp offers
exactly one control for it — the event-level Card checkbox. So that
checkbox now governs every wave on save: ticking it enables fiat on all
of them, unticking disables it on all of them, and each wave settles in
the event's fiat currency rather than whatever it happened to hold.
Waves created before this carry the backend's "GBP" default even on a
EUR event, which is what demo showed.

Removes the per-wave switch added a commit ago. With the event-level
setting applied on save it would be overwritten silently, and a control
that disagrees with what gets stored is worse than no control.

The trade-off, stated plainly: a per-wave fiat setting made in the
LNbits admin is overwritten the next time the event is saved from the
webapp. That is acceptable while the webapp has no way to represent one
— and it is deliberate rather than accidental, which the previous
behaviour was not.

4 tests: fiat on and off across every wave, the event currency winning
over a stale per-wave one, and everything else about each wave left
alone. 83 pass; vue-tsc, prettier and the production build clean.
2026-09-30 21:43:57 +02:00
4ce36598ce fix(events): ticket waves inherit the event's fiat setting
Reported from aio-demo: an event with card enabled, Card offered at
checkout, and the purchase refused with "Fiat payments are not enabled
for this ticket wave."

Two causes, both introduced with wave support in #176.

**New waves never carried the flag.** `newWaveRow` set id, title, dates,
currency, capacity and price — not `allow_fiat`. Fiat is a per-wave
opt-in, so every wave the webapp created silently refused card payments
however the event-level toggle was set. The editor now seeds new waves
from the event, the way the LNbits admin dialog seeds one from the
primary wave, and exposes a per-wave switch so an existing wave can be
corrected — the LNbits admin has had that control all along, which is
why the event looked fiat-enabled there while its waves were not.

**The rail list ignored the wave.** `effectivePaymentMethods` returned
the organiser's explicit `payment_methods` before consulting anything
else, so the card button appeared regardless. That list is event-level
while fiat is per-wave, and the webapp always sets it, so the explicit
path is the normal one rather than the exception.

The wave is now a separate argument rather than being folded into
`allowFiat`: that one is the event's legacy flag and means something
different, and conflating them broke two existing tests — correctly,
which is how the design fault showed up. Matches the backend's
`effective_payment_methods(event, wave)` (aiolabs/events b55d686).

7 tests, including that an unset wave flag reads as no-fiat (how the
backend reads it) and that the event-level question stays unfiltered.
79 pass; vue-tsc, prettier and the production build clean.

Needs the matching events release to be deployed for the published
NIP-52 tags to agree; the webapp half stands alone.
2026-09-30 19:34:48 +02:00
7 changed files with 226 additions and 35 deletions

View file

@ -342,8 +342,12 @@ const waveErrors = computed(() =>
validateWaveRows(extraWaves.value, new Set(knownWaveIds.value)),
);
const hasWaveErrors = computed(() => Object.keys(waveErrors.value).length > 0);
/** New waves inherit the primary wave's currency. */
/** New waves inherit the primary wave's currency and fiat settings. */
const primaryCurrency = computed(() => form.values.currency ?? "sat");
const primaryAllowsFiat = computed(
() => form.values.payment_methods?.includes("fiat") ?? false,
);
const primaryFiatCurrency = computed(() => form.values.fiat_currency ?? "USD");
async function populateFromEvent(event: TicketedEvent) {
isPopulating.value = true;
@ -594,7 +598,28 @@ const onSubmit = form.handleSubmit(
notification_subject: formValues.notification_subject,
notification_body: formValues.notification_body,
promo_codes: toWirePromoCodes(promoRows.value),
ticket_waves: [primaryWave, ...extraWaves.value],
// Fiat is a per-wave opt-in in the extension, but the webapp
// offers ONE event-level control for it (the Card checkbox), so
// that checkbox governs every wave: ticking Card enables fiat on
// all of them, unticking disables it on all of them. Without this a
// wave added here refuses card payments while the event itself
// reads as fiat-enabled — what organisers hit on demo.
//
// Deliberately simple for now: it does mean a per-wave setting made
// in the LNbits admin is overwritten next time the event is saved
// from here. Acceptable while the webapp has no per-wave control to
// represent one, and better than a control that silently disagrees
// with what gets stored.
ticket_waves: [primaryWave, ...extraWaves.value].map((wave) => ({
...wave,
allow_fiat: acceptsFiat,
// The event's fiat currency, unconditionally — not a fallback to
// whatever the wave happened to hold. Waves created before this
// carry the backend's "GBP" default, and a wave settling in a
// different currency than the event advertises is not a state the
// webapp can represent or an organiser can have asked for.
fiat_currency: formValues.fiat_currency,
})),
};
if (isEditMode.value) {
@ -1215,6 +1240,8 @@ const handleOpenChange = (open: boolean) => {
v-model="extraWaves"
:known-ids="knownWaveIds"
:currency="primaryCurrency"
:allow-fiat="primaryAllowsFiat"
:fiat-currency="primaryFiatCurrency"
:disabled="isLoading"
/>
</CollapsibleContent>

View file

@ -298,12 +298,16 @@ const isFiatPending = ref(false);
const fiatError = ref<string | null>(null);
const enabledRails = computed(() =>
effectivePaymentMethods({
// Fiat is a per-wave opt-in; `event.allow_fiat` is only the primary
// wave's and can disagree with the tier being bought.
allowFiat: pricing.value.allow_fiat,
paymentMethods: props.event.payment_methods,
}),
effectivePaymentMethods(
{
allowFiat: props.event.allow_fiat,
paymentMethods: props.event.payment_methods,
},
// Fiat is a per-wave opt-in, and the organiser's explicit rail list is
// event-level — without the wave here the dialog offers a card button
// the purchase endpoint then refuses.
pricing.value,
),
);
const canChooseFiat = computed(() => enabledRails.value.includes("fiat"));
const isPriceInSats = computed(

View file

@ -27,6 +27,12 @@ const props = defineProps<{
knownIds?: string[];
/** Currency of the primary wave; new rows inherit it. */
currency?: string;
/** Whether the event accepts fiat, and in what. New rows inherit both so
* the in-memory row is right before saving; on save the dialog applies
* the event-level Card checkbox to every wave anyway, which is why
* there is no per-wave switch here — it would be overwritten. */
allowFiat?: boolean;
fiatCurrency?: string;
disabled?: boolean;
}>();
const emit = defineEmits<{ "update:modelValue": [waves: TicketWave[]] }>();
@ -47,7 +53,11 @@ function update(index: number, patch: Partial<TicketWave>) {
function add() {
emit("update:modelValue", [
...props.modelValue,
newWaveRow({ currency: props.currency ?? "sat" }),
newWaveRow({
currency: props.currency ?? "sat",
allow_fiat: props.allowFiat ?? false,
fiat_currency: props.fiatCurrency ?? "USD",
}),
]);
}

View file

@ -1,21 +1,79 @@
import { describe, it, expect } from 'vitest'
import { effectivePaymentMethods } from './paymentMethods'
import { describe, it, expect } from "vitest";
import { effectivePaymentMethods } from "./paymentMethods";
describe('effectivePaymentMethods', () => {
it('falls back to the legacy allowFiat rule when no list is published', () => {
expect(effectivePaymentMethods({ allowFiat: false })).toEqual(['lightning'])
expect(effectivePaymentMethods({ allowFiat: true })).toEqual(['lightning', 'fiat'])
expect(effectivePaymentMethods(undefined)).toEqual(['lightning'])
})
describe("effectivePaymentMethods", () => {
it("falls back to the legacy allowFiat rule when no list is published", () => {
expect(effectivePaymentMethods({ allowFiat: false })).toEqual([
"lightning",
]);
expect(effectivePaymentMethods({ allowFiat: true })).toEqual([
"lightning",
"fiat",
]);
expect(effectivePaymentMethods(undefined)).toEqual(["lightning"]);
});
it('honours an explicit list, including fiat-only events', () => {
expect(effectivePaymentMethods({ allowFiat: true, paymentMethods: ['fiat'] })).toEqual(['fiat'])
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['lightning', 'fiat'] }))
.toEqual(['lightning', 'fiat'])
})
it("honours an explicit list, including fiat-only events", () => {
expect(
effectivePaymentMethods({ allowFiat: true, paymentMethods: ["fiat"] }),
).toEqual(["fiat"]);
expect(
effectivePaymentMethods({
allowFiat: false,
paymentMethods: ["lightning", "fiat"],
}),
).toEqual(["lightning", "fiat"]);
});
it('drops unknown rails and falls back when nothing known remains', () => {
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain'] })).toEqual(['lightning'])
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain', 'fiat'] })).toEqual(['fiat'])
})
})
it("drops unknown rails and falls back when nothing known remains", () => {
expect(
effectivePaymentMethods({
allowFiat: false,
paymentMethods: ["onchain"],
}),
).toEqual(["lightning"]);
expect(
effectivePaymentMethods({
allowFiat: false,
paymentMethods: ["onchain", "fiat"],
}),
).toEqual(["fiat"]);
});
});
describe("effectivePaymentMethods — per-wave fiat", () => {
const railsOn = { allowFiat: true, paymentMethods: ["lightning", "fiat"] };
it("drops card for a wave that cannot take fiat", () => {
// Reported from aio-demo: event fiat on, Card offered, purchase
// refused with "Fiat payments are not enabled for this ticket wave".
expect(effectivePaymentMethods(railsOn, { allow_fiat: false })).toEqual([
"lightning",
]);
});
it("keeps card for a wave that can", () => {
expect(effectivePaymentMethods(railsOn, { allow_fiat: true })).toEqual([
"lightning",
"fiat",
]);
});
it("treats an unset wave flag as no fiat — it is how the backend reads it", () => {
expect(effectivePaymentMethods(railsOn, {})).toEqual(["lightning"]);
});
it("leaves the event-level question unfiltered", () => {
expect(effectivePaymentMethods(railsOn)).toEqual(["lightning", "fiat"]);
expect(effectivePaymentMethods(railsOn, null)).toEqual([
"lightning",
"fiat",
]);
});
it("filters the legacy allowFiat path too", () => {
expect(
effectivePaymentMethods({ allowFiat: true }, { allow_fiat: false }),
).toEqual(["lightning"]);
});
});

View file

@ -1,6 +1,6 @@
export type PaymentRailId = 'lightning' | 'fiat'
export type PaymentRailId = "lightning" | "fiat";
const KNOWN_RAILS: PaymentRailId[] = ['lightning', 'fiat']
const KNOWN_RAILS: PaymentRailId[] = ["lightning", "fiat"];
/**
* Rails a buyer may use for an event.
@ -13,10 +13,31 @@ const KNOWN_RAILS: PaymentRailId[] = ['lightning', 'fiat']
*/
export function effectivePaymentMethods(
info: { allowFiat?: boolean; paymentMethods?: string[] } | null | undefined,
/** The wave being bought. Omit for the event-level question ("which
* rails did the organiser enable at all"). */
wave?: { allow_fiat?: boolean } | null,
): PaymentRailId[] {
const explicit = (info?.paymentMethods ?? []).filter(
(m): m is PaymentRailId => (KNOWN_RAILS as string[]).includes(m),
)
if (explicit.length > 0) return explicit
return info?.allowFiat ? ['lightning', 'fiat'] : ['lightning']
);
const rails =
explicit.length > 0
? explicit
: info?.allowFiat
? (["lightning", "fiat"] as PaymentRailId[])
: (["lightning"] as PaymentRailId[]);
// Fiat is a PER-WAVE opt-in, while the rail list above is event-level.
// Asking about a specific wave means asking what a buyer can actually
// use for it, so drop a rail that wave cannot honour — otherwise the
// dialog renders a card button that `api_ticket_create` refuses with
// "Fiat payments are not enabled for this ticket wave".
//
// A separate argument rather than reusing `info.allowFiat`: that one is
// the EVENT's legacy flag and means something different. The backend
// keeps the same split (`effective_payment_methods(event, wave)`).
if (wave && wave.allow_fiat !== true) {
return rails.filter((m) => m !== "fiat");
}
return rails;
}

View file

@ -327,3 +327,66 @@ describe("the primary-wave write-through a create/edit form performs", () => {
expect(waves[1]).toEqual(REGULAR);
});
});
describe("newWaveRow — fiat inheritance", () => {
it("defaults to no fiat, matching what the backend stores", () => {
expect(newWaveRow().allow_fiat).toBe(false);
});
it("inherits the event settings when the editor passes them", () => {
// Without this a wave created on a fiat-enabled event silently refuses
// card payments, and the organiser has no way to see why.
const row = newWaveRow({
allow_fiat: true,
fiat_currency: "EUR",
currency: "EUR",
});
expect(row.allow_fiat).toBe(true);
expect(row.fiat_currency).toBe("EUR");
});
});
describe("the Card checkbox governs every wave", () => {
// The webapp has one event-level fiat control, so on save it is applied
// to all waves. Pinned because getting it wrong is silent: the event
// reads as fiat-enabled while a wave refuses card payments, and the
// buyer only finds out at checkout.
const applyEventFiat = (
waves: TicketWave[],
acceptsFiat: boolean,
cur: string,
) =>
waves.map((w) => ({ ...w, allow_fiat: acceptsFiat, fiat_currency: cur }));
const waves = [
wave({ id: "primary", allow_fiat: true, fiat_currency: "EUR" }),
wave({ id: "early", allow_fiat: false, fiat_currency: "GBP" }),
wave({ id: "late", allow_fiat: false, fiat_currency: "GBP" }),
];
it("ticking Card enables fiat on every wave, not just the primary", () => {
const out = applyEventFiat(waves, true, "EUR");
expect(out.map((w) => w.allow_fiat)).toEqual([true, true, true]);
expect(out.map((w) => w.fiat_currency)).toEqual(["EUR", "EUR", "EUR"]);
});
it("unticking Card disables it on every wave", () => {
expect(
applyEventFiat(waves, false, "EUR").map((w) => w.allow_fiat),
).toEqual([false, false, false]);
});
it("overwrites a stale wave fiat currency with the event's", () => {
// Waves created before this carry the backend's "GBP" default even on
// a EUR event, which is what demo showed.
const out = applyEventFiat(waves, true, "EUR");
expect(out.every((w) => w.fiat_currency === "EUR")).toBe(true);
});
it("leaves everything else about each wave alone", () => {
const [, early] = applyEventFiat(waves, true, "EUR");
expect(early.id).toBe("early");
expect(early.price_per_ticket).toBe(waves[1].price_per_ticket);
expect(early.amount_tickets).toBe(waves[1].amount_tickets);
});
});

View file

@ -289,6 +289,14 @@ export function newWaveRow(defaults: Partial<TicketWave> = {}): TicketWave {
// unlimited (aiolabs/events#34).
amount_tickets: 1,
price_per_ticket: 0,
// Fiat is a PER-WAVE opt-in. A wave created without it cannot take
// card payments however the event-level toggle is set, and the
// organiser gets "Fiat payments are not enabled for this ticket wave"
// at checkout with no way to see why. Callers pass the event's
// setting so a new wave inherits it, the way the LNbits admin dialog
// seeds one from the primary wave.
allow_fiat: false,
fiat_currency: "USD",
...defaults,
};
}