Ticket waves follow the event's fiat setting #179
7 changed files with 226 additions and 35 deletions
|
|
@ -342,8 +342,12 @@ const waveErrors = computed(() =>
|
||||||
validateWaveRows(extraWaves.value, new Set(knownWaveIds.value)),
|
validateWaveRows(extraWaves.value, new Set(knownWaveIds.value)),
|
||||||
);
|
);
|
||||||
const hasWaveErrors = computed(() => Object.keys(waveErrors.value).length > 0);
|
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 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) {
|
async function populateFromEvent(event: TicketedEvent) {
|
||||||
isPopulating.value = true;
|
isPopulating.value = true;
|
||||||
|
|
@ -594,7 +598,28 @@ const onSubmit = form.handleSubmit(
|
||||||
notification_subject: formValues.notification_subject,
|
notification_subject: formValues.notification_subject,
|
||||||
notification_body: formValues.notification_body,
|
notification_body: formValues.notification_body,
|
||||||
promo_codes: toWirePromoCodes(promoRows.value),
|
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) {
|
if (isEditMode.value) {
|
||||||
|
|
@ -1215,6 +1240,8 @@ const handleOpenChange = (open: boolean) => {
|
||||||
v-model="extraWaves"
|
v-model="extraWaves"
|
||||||
:known-ids="knownWaveIds"
|
:known-ids="knownWaveIds"
|
||||||
:currency="primaryCurrency"
|
:currency="primaryCurrency"
|
||||||
|
:allow-fiat="primaryAllowsFiat"
|
||||||
|
:fiat-currency="primaryFiatCurrency"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
/>
|
/>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
|
|
|
||||||
|
|
@ -298,12 +298,16 @@ const isFiatPending = ref(false);
|
||||||
const fiatError = ref<string | null>(null);
|
const fiatError = ref<string | null>(null);
|
||||||
|
|
||||||
const enabledRails = computed(() =>
|
const enabledRails = computed(() =>
|
||||||
effectivePaymentMethods({
|
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: props.event.allow_fiat,
|
||||||
allowFiat: pricing.value.allow_fiat,
|
paymentMethods: props.event.payment_methods,
|
||||||
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 canChooseFiat = computed(() => enabledRails.value.includes("fiat"));
|
||||||
const isPriceInSats = computed(
|
const isPriceInSats = computed(
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ const props = defineProps<{
|
||||||
knownIds?: string[];
|
knownIds?: string[];
|
||||||
/** Currency of the primary wave; new rows inherit it. */
|
/** Currency of the primary wave; new rows inherit it. */
|
||||||
currency?: string;
|
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;
|
disabled?: boolean;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{ "update:modelValue": [waves: TicketWave[]] }>();
|
const emit = defineEmits<{ "update:modelValue": [waves: TicketWave[]] }>();
|
||||||
|
|
@ -47,7 +53,11 @@ function update(index: number, patch: Partial<TicketWave>) {
|
||||||
function add() {
|
function add() {
|
||||||
emit("update:modelValue", [
|
emit("update:modelValue", [
|
||||||
...props.modelValue,
|
...props.modelValue,
|
||||||
newWaveRow({ currency: props.currency ?? "sat" }),
|
newWaveRow({
|
||||||
|
currency: props.currency ?? "sat",
|
||||||
|
allow_fiat: props.allowFiat ?? false,
|
||||||
|
fiat_currency: props.fiatCurrency ?? "USD",
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,79 @@
|
||||||
import { describe, it, expect } from 'vitest'
|
import { describe, it, expect } from "vitest";
|
||||||
import { effectivePaymentMethods } from './paymentMethods'
|
import { effectivePaymentMethods } from "./paymentMethods";
|
||||||
|
|
||||||
describe('effectivePaymentMethods', () => {
|
describe("effectivePaymentMethods", () => {
|
||||||
it('falls back to the legacy allowFiat rule when no list is published', () => {
|
it("falls back to the legacy allowFiat rule when no list is published", () => {
|
||||||
expect(effectivePaymentMethods({ allowFiat: false })).toEqual(['lightning'])
|
expect(effectivePaymentMethods({ allowFiat: false })).toEqual([
|
||||||
expect(effectivePaymentMethods({ allowFiat: true })).toEqual(['lightning', 'fiat'])
|
"lightning",
|
||||||
expect(effectivePaymentMethods(undefined)).toEqual(['lightning'])
|
]);
|
||||||
})
|
expect(effectivePaymentMethods({ allowFiat: true })).toEqual([
|
||||||
|
"lightning",
|
||||||
|
"fiat",
|
||||||
|
]);
|
||||||
|
expect(effectivePaymentMethods(undefined)).toEqual(["lightning"]);
|
||||||
|
});
|
||||||
|
|
||||||
it('honours an explicit list, including fiat-only events', () => {
|
it("honours an explicit list, including fiat-only events", () => {
|
||||||
expect(effectivePaymentMethods({ allowFiat: true, paymentMethods: ['fiat'] })).toEqual(['fiat'])
|
expect(
|
||||||
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['lightning', 'fiat'] }))
|
effectivePaymentMethods({ allowFiat: true, paymentMethods: ["fiat"] }),
|
||||||
.toEqual(['lightning', 'fiat'])
|
).toEqual(["fiat"]);
|
||||||
})
|
expect(
|
||||||
|
effectivePaymentMethods({
|
||||||
|
allowFiat: false,
|
||||||
|
paymentMethods: ["lightning", "fiat"],
|
||||||
|
}),
|
||||||
|
).toEqual(["lightning", "fiat"]);
|
||||||
|
});
|
||||||
|
|
||||||
it('drops unknown rails and falls back when nothing known remains', () => {
|
it("drops unknown rails and falls back when nothing known remains", () => {
|
||||||
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain'] })).toEqual(['lightning'])
|
expect(
|
||||||
expect(effectivePaymentMethods({ allowFiat: false, paymentMethods: ['onchain', 'fiat'] })).toEqual(['fiat'])
|
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.
|
* Rails a buyer may use for an event.
|
||||||
|
|
@ -13,10 +13,31 @@ const KNOWN_RAILS: PaymentRailId[] = ['lightning', 'fiat']
|
||||||
*/
|
*/
|
||||||
export function effectivePaymentMethods(
|
export function effectivePaymentMethods(
|
||||||
info: { allowFiat?: boolean; paymentMethods?: string[] } | null | undefined,
|
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[] {
|
): PaymentRailId[] {
|
||||||
const explicit = (info?.paymentMethods ?? []).filter(
|
const explicit = (info?.paymentMethods ?? []).filter(
|
||||||
(m): m is PaymentRailId => (KNOWN_RAILS as string[]).includes(m),
|
(m): m is PaymentRailId => (KNOWN_RAILS as string[]).includes(m),
|
||||||
)
|
);
|
||||||
if (explicit.length > 0) return explicit
|
const rails =
|
||||||
return info?.allowFiat ? ['lightning', 'fiat'] : ['lightning']
|
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,66 @@ describe("the primary-wave write-through a create/edit form performs", () => {
|
||||||
expect(waves[1]).toEqual(REGULAR);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,14 @@ export function newWaveRow(defaults: Partial<TicketWave> = {}): TicketWave {
|
||||||
// unlimited (aiolabs/events#34).
|
// unlimited (aiolabs/events#34).
|
||||||
amount_tickets: 1,
|
amount_tickets: 1,
|
||||||
price_per_ticket: 0,
|
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,
|
...defaults,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue