Ticket waves follow the event's fiat setting #179
7 changed files with 174 additions and 34 deletions
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.
commit
4ce36598ce
|
|
@ -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;
|
||||
|
|
@ -1215,6 +1219,8 @@ const handleOpenChange = (open: boolean) => {
|
|||
v-model="extraWaves"
|
||||
:known-ids="knownWaveIds"
|
||||
:currency="primaryCurrency"
|
||||
:allow-fiat="primaryAllowsFiat"
|
||||
:fiat-currency="primaryFiatCurrency"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Plus, Trash2 } from "lucide-vue-next";
|
||||
|
|
@ -27,6 +28,11 @@ 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:
|
||||
* fiat is a per-wave opt-in, so a wave created without it refuses card
|
||||
* payments no matter what the event-level toggle says. */
|
||||
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",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -176,6 +186,21 @@ function isSoldOut(row: TicketWave): boolean {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="allowFiat"
|
||||
class="flex items-center justify-between rounded-md bg-muted/40 px-2 py-1.5"
|
||||
>
|
||||
<span class="text-xs">
|
||||
Accept card for this wave
|
||||
<span class="text-muted-foreground">({{ row.fiat_currency }})</span>
|
||||
</span>
|
||||
<Switch
|
||||
:model-value="row.allow_fiat ?? false"
|
||||
:disabled="disabled"
|
||||
@update:model-value="update(index, { allow_fiat: Boolean($event) })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errors[index]" class="text-xs text-destructive">
|
||||
{{ errors[index] }}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -327,3 +327,21 @@ 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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue