diff --git a/src/modules/events/components/CreateEventDialog.vue b/src/modules/events/components/CreateEventDialog.vue index ad8d9c5..b9ad6c1 100644 --- a/src/modules/events/components/CreateEventDialog.vue +++ b/src/modules/events/components/CreateEventDialog.vue @@ -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" /> diff --git a/src/modules/events/components/PurchaseTicketDialog.vue b/src/modules/events/components/PurchaseTicketDialog.vue index a1737fb..3f2dd66 100644 --- a/src/modules/events/components/PurchaseTicketDialog.vue +++ b/src/modules/events/components/PurchaseTicketDialog.vue @@ -298,12 +298,16 @@ const isFiatPending = ref(false); const fiatError = ref(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( diff --git a/src/modules/events/components/TicketWavesEditor.vue b/src/modules/events/components/TicketWavesEditor.vue index 0812886..b152176 100644 --- a/src/modules/events/components/TicketWavesEditor.vue +++ b/src/modules/events/components/TicketWavesEditor.vue @@ -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) { 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", + }), ]); } diff --git a/src/modules/events/lib/paymentMethods.spec.ts b/src/modules/events/lib/paymentMethods.spec.ts index 72c80f2..1991ef1 100644 --- a/src/modules/events/lib/paymentMethods.spec.ts +++ b/src/modules/events/lib/paymentMethods.spec.ts @@ -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"]); + }); +}); diff --git a/src/modules/events/lib/paymentMethods.ts b/src/modules/events/lib/paymentMethods.ts index 70e08e9..1b73b5e 100644 --- a/src/modules/events/lib/paymentMethods.ts +++ b/src/modules/events/lib/paymentMethods.ts @@ -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; } diff --git a/src/modules/events/lib/ticketWaves.spec.ts b/src/modules/events/lib/ticketWaves.spec.ts index ce2bad2..c827510 100644 --- a/src/modules/events/lib/ticketWaves.spec.ts +++ b/src/modules/events/lib/ticketWaves.spec.ts @@ -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); + }); +}); diff --git a/src/modules/events/lib/ticketWaves.ts b/src/modules/events/lib/ticketWaves.ts index 9c2e710..11ad8b4 100644 --- a/src/modules/events/lib/ticketWaves.ts +++ b/src/modules/events/lib/ticketWaves.ts @@ -289,6 +289,14 @@ export function newWaveRow(defaults: Partial = {}): 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, }; }