feat(activities): payment-rails pattern + provider-aware checkout #68

Merged
padreug merged 7 commits from payment-rails-pattern into dev 2026-05-23 21:18:54 +00:00
Owner

Summary

Resolves the clunky payment surface introduced by the v1.6.1 fiat
exposure: two "Currency" selectors with no relationship, a bare
fiat button on the buyer side that conflicts with the price unit,
and no live conversion preview. Reshapes the dialogs around one
canonical vocabulary and extracts the cross-cutting bits into the
base module so restaurant + marketplace can adopt the pattern as
their backends gain fiat support.

  • Vocabulary — "Price currency" (currency), "Fiat currency"
    (fiat_currency), "Payment method" (rail), "Also accept fiat"
    (allow_fiat). The bare word "Currency" is banned in
    payment-context labels; the buyer-side "Pay in fiat" / "Fiat"
    button is replaced by per-provider buttons (Stripe, PayPal,
    Square, SEPA).
  • Shared primitives in src/modules/base/:
    • useFiatProviders reads the per-session User.fiat_providers
      from GET /api/v1/auth (today the same list for organizer +
      buyer because LNbits configures providers globally).
    • usePriceConversion over the existing /api/v1/conversion
      endpoint with a 60s cache; best-effort, null on failure.
    • PaymentMethodSelector — rail picker with rail+provider
      dispatch fields and label+icon+badge display fields.
    • FiatToggleField — owner-side switch + conditional
      fiat-currency dropdown; auto-disabled with a setup-instructions
      tooltip when the user has no providers; silently mirrors
      fiat_currency = denomination for non-sat price units.
    • PriceConversionPreview — muted "≈ X.XX USD" line.
  • CreateEventDialog restructures the bottom of the form into
    two semantic sections (Pricing · Payment methods). Lightning
    shows as an informational chip; the fiat block is now the
    shared <FiatToggleField>. Zod superRefine requires
    fiat_currency only on the surface where it's actually
    exposed (allow_fiat && currency === 'sat'). Submit drops
    fiat_currency when fiat is off so the backend payload stays
    honest.
  • PurchaseTicketDialog swaps the inline two-button block for
    <PaymentMethodSelector> driven by useFiatProviders, so a
    multi-provider instance shows one button per provider. The
    Lightning button picks up a "≈ N sats" badge whenever the price
    is denominated in fiat. A new <PriceConversionPreview> line
    under the headline price shows the inverse case (sat-denominated
    event with fiat enabled). Explanatory copy makes the equivalence
    explicit: same amount via different rails, rates are estimates,
    exact amount locks in at checkout.
  • CLAUDE.md gains a "Payment rails pattern" section
    documenting the vocabulary, primitives, fiat-providers-are-global
    architecture, and the "adding a new provider" recipe.

This branch depends on the v1.6.1 types/API alignment commit
(620919d) it builds on top of. The sibling notification-config
branch also carries that commit; whichever PR merges first lands
it on dev, the other rebases trivially.

Test plan

  • npm run build clean (vue-tsc + vite) — passes locally.
  • As a user with fiat_providers=['stripe']:
    • Create Event shows the new Pricing + Payment methods
      sections; Price currency=sat + toggle "Also accept fiat"
      on → Fiat currency dropdown appears and is required.
    • Switching Price currency to USD hides the
      Fiat currency dropdown; submit payload contains
      fiat_currency: 'USD'.
  • As a user with no fiat providers: "Also accept fiat" switch
    is disabled with the setup-instructions tooltip.
  • As buyer on an instance with stripe and square enabled,
    viewing a currency=USD, allow_fiat=true event: purchase
    dialog shows Lightning (with "≈ N sats" badge) + one Stripe
    button + one Square button; clicking each forwards
    payment_method: 'fiat', fiat_provider: 'stripe' /
    'square'.
  • As buyer on a currency=sat, allow_fiat=true, fiat_currency=EUR event: headline "N sats" + secondary
    "Equivalent ~€X.XX EUR if paid in fiat".
  • Edit flow: open an existing event → form populates
    correctly; toggling "Also accept fiat" off and saving sends
    allow_fiat: false.

🤖 Generated with Claude Code

## Summary Resolves the clunky payment surface introduced by the v1.6.1 fiat exposure: two "Currency" selectors with no relationship, a bare fiat button on the buyer side that conflicts with the price unit, and no live conversion preview. Reshapes the dialogs around one canonical vocabulary and extracts the cross-cutting bits into the base module so restaurant + marketplace can adopt the pattern as their backends gain fiat support. - **Vocabulary** — "Price currency" (`currency`), "Fiat currency" (`fiat_currency`), "Payment method" (rail), "Also accept fiat" (`allow_fiat`). The bare word "Currency" is banned in payment-context labels; the buyer-side "Pay in fiat" / "Fiat" button is replaced by per-provider buttons (`Stripe`, `PayPal`, `Square`, `SEPA`). - **Shared primitives** in `src/modules/base/`: - `useFiatProviders` reads the per-session `User.fiat_providers` from `GET /api/v1/auth` (today the same list for organizer + buyer because LNbits configures providers globally). - `usePriceConversion` over the existing `/api/v1/conversion` endpoint with a 60s cache; best-effort, null on failure. - `PaymentMethodSelector` — rail picker with rail+provider dispatch fields and label+icon+badge display fields. - `FiatToggleField` — owner-side switch + conditional fiat-currency dropdown; auto-disabled with a setup-instructions tooltip when the user has no providers; silently mirrors `fiat_currency = denomination` for non-sat price units. - `PriceConversionPreview` — muted "≈ X.XX USD" line. - **CreateEventDialog** restructures the bottom of the form into two semantic sections (Pricing · Payment methods). Lightning shows as an informational chip; the fiat block is now the shared `<FiatToggleField>`. Zod superRefine requires `fiat_currency` only on the surface where it's actually exposed (`allow_fiat && currency === 'sat'`). Submit drops `fiat_currency` when fiat is off so the backend payload stays honest. - **PurchaseTicketDialog** swaps the inline two-button block for `<PaymentMethodSelector>` driven by `useFiatProviders`, so a multi-provider instance shows one button per provider. The Lightning button picks up a "≈ N sats" badge whenever the price is denominated in fiat. A new `<PriceConversionPreview>` line under the headline price shows the inverse case (sat-denominated event with fiat enabled). Explanatory copy makes the equivalence explicit: same amount via different rails, rates are estimates, exact amount locks in at checkout. - **CLAUDE.md** gains a "Payment rails pattern" section documenting the vocabulary, primitives, fiat-providers-are-global architecture, and the "adding a new provider" recipe. This branch depends on the v1.6.1 types/API alignment commit (`620919d`) it builds on top of. The sibling `notification-config` branch also carries that commit; whichever PR merges first lands it on `dev`, the other rebases trivially. ## Test plan - [x] `npm run build` clean (vue-tsc + vite) — passes locally. - [x] As a user with `fiat_providers=['stripe']`: - [x] Create Event shows the new Pricing + Payment methods sections; `Price currency=sat` + toggle "Also accept fiat" on → `Fiat currency` dropdown appears and is required. - [ ] Switching `Price currency` to `USD` hides the `Fiat currency` dropdown; submit payload contains `fiat_currency: 'USD'`. - [ ] As a user with no fiat providers: "Also accept fiat" switch is disabled with the setup-instructions tooltip. - [ ] As buyer on an instance with `stripe` and `square` enabled, viewing a `currency=USD, allow_fiat=true` event: purchase dialog shows Lightning (with "≈ N sats" badge) + one Stripe button + one Square button; clicking each forwards `payment_method: 'fiat', fiat_provider: 'stripe'` / `'square'`. - [ ] As buyer on a `currency=sat, allow_fiat=true, fiat_currency=EUR` event: headline "N sats" + secondary "Equivalent ~€X.XX EUR if paid in fiat". - [ ] Edit flow: open an existing event → form populates correctly; toggling "Also accept fiat" off and saving sends `allow_fiat: false`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
The backend rebase brought in PR #50 (fiat checkout + email/Nostr
ticket notifications), v1.6.0 (custom notification subject/body), and
PR #51 (resend-email endpoint). The webapp types lagged.

Aligns the type surface in src/modules/activities/types/ticket.ts:

- EventExtra (with notification toggles + custom subject/body), promo
  codes, conditional event config.
- ActivityTicketExtra mirroring backend's TicketExtra (nostr_identifier,
  email/nostr notification_sent flags, refund state).
- TicketedEvent + CreateEventRequest gain allow_fiat, fiat_currency, extra.
- TicketPurchaseInvoice extended for fiat: paymentRequest now optional,
  fiatPaymentRequest + fiatProvider + isFiat added. **Closes a latent
  blocker**: a backend response with is_fiat=true would have lost the
  fiat URL during deserialization (silent crash on QR generation).
- New CreateTicketRequest type for the POST /tickets/{id} body, with
  the v1.6.1 payment_method + fiat_provider + nostr_identifier fields.

TicketApiService:
- requestTicket() accepts the new optional fields (paymentMethod,
  fiatProvider, promoCode, refundAddress, nostrIdentifier) and
  deserializes the full TicketPurchaseInvoice shape including fiat.
- fetchUserTickets() / validateTicket() / new resendTicketEmail()
  thread the extra metadata through.

useTicketPurchase composable rejects fiat responses with a clear
error (the QR-and-bolt11 path doesn't know fiat); the eventual UI
selector will live in PurchaseTicketDialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both sides of the fiat-payment surface introduced by events v1.4.0:

CreateEventDialog — organizer-side opt-in:
- New "Accept fiat payments" switch (allow_fiat) + fiat currency
  picker (USD/EUR/GBP/CHF). Toggle is always shipped on
  create/edit so a true→false flip propagates correctly.
- Hint copy notes that the host's LNbits admin needs a configured
  fiat provider (Stripe etc.) for the toggle to actually work at
  purchase time.

PurchaseTicketDialog — buyer-side method selector:
- Two-button selector (Lightning / Fiat) shown only when the event
  has `allow_fiat=true`. Hidden entirely for Lightning-only events.
- Lightning path: unchanged (uses useTicketPurchase composable).
- Fiat path: posts to the API with `payment_method=fiat`, then
  surfaces a "Open <provider> checkout" button that opens the
  returned fiat_payment_request URL in a new tab. Payment
  confirmation happens via webhook on the backend; ticket appears
  in My Tickets on next reload.

EventsPage threads the new fiat fields through `selectedEvent` so
the dialog sees them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Activities is the first module to mix Lightning + fiat rails; restaurant
and marketplace will follow. Extract the cross-cutting bits to the base
module so the next adoption is a wiring exercise:

- useFiatProviders: reactive `User.fiat_providers` (today the same list
  for organizer + buyer because LNbits configures providers globally),
  plus `providerMeta()` for label/icon hints.
- usePriceConversion: `convert()` + reactive `useLivePreview()` over
  the existing `/api/v1/conversion` endpoint, 60s cache, null on
  transient failure.
- PaymentMethodSelector: buyer-side rail picker. `PaymentMethod.id`
  enumerates rails (`lightning | fiat | cash | internal | …`) with
  `provider` for the fiat case so a multi-provider instance shows one
  button per provider instead of a bare "Fiat" catch-all.
- FiatToggleField: organizer-side switch + conditional fiat-currency
  dropdown. Auto-disables with a setup-instructions tooltip when the
  user has no providers; silently mirrors fiat_currency to a non-sat
  price denomination to keep the backend payload consistent.
- PriceConversionPreview: muted "≈ X.XX USD" line for surfaces where
  the price denomination differs from the chosen rail's currency.

LnbitsAPI.getConversion wraps the conversion endpoint so the composable
goes through the existing API service rather than raw fetch. CLAUDE.md
gains a "Payment rails pattern" section documenting the canonical
vocabulary ("Price currency" / "Fiat currency" / "Payment method" /
"Also accept fiat" — bare "Currency" and "Pay in fiat" are banned in
payment-context UI labels) and the fiat-providers-are-global note.

The pre-existing `prvkey` comment on User picks up an inline allowlist
marker so the secret scanner stops flagging this file on every commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Split the bottom of the create/edit form into two semantic sections
that read in the canonical vocabulary:

  Pricing
    Tickets · Price · Price currency   ← renamed from bare "Currency"
  Payment methods
    Lightning — always on (informational chip)
    <FiatToggleField/>                  ← replaces the inline switch
                                          + raw fiat_currency dropdown

The toggle field handles the conditional dropdown (hide + auto-mirror
when the price denomination IS the fiat currency) and the disabled-
with-tooltip state when the user has no configured provider, so the
parent form just supplies field names + the denomination value.

Zod superRefine grows a check that requires `fiat_currency` only in
the surface where the toggle exposes the dropdown — `allow_fiat &&
currency === 'sat'`. Submit-time payload drops `fiat_currency` when
`allow_fiat` is off so we don't persist a rail-currency the backend
won't use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The buyer-side payment-method block now surfaces one button per
configured fiat provider — Stripe, PayPal, Square, SEPA — rather than
a single bare "Fiat" catch-all. Buttons read in provider names so the
buyer never has to guess what rail backs each choice; the dispatch on
click forwards both `rail` and `provider` to the existing
`ticketApi.requestTicket` signature.

PaymentMethodSelector + useFiatProviders from the base module drive
the list. The Lightning button picks up a "≈ N sats" badge whenever
the event price is denominated in fiat, so the buyer sees the live
sat charge alongside the headline price. A new conversion-preview
line under the headline shows the sat→fiat estimate in the inverse
case (sat-denominated event with fiat enabled), giving the rail-vs-
unit asymmetry an explicit place in the UI.

Explanatory copy makes the equivalence explicit: both methods charge
the same amount, rates are estimates, exact amount locks in at
checkout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous version called useField directly with a getter for the
field name. That created a child-local field rather than connecting
to the parent form's allow_fiat / fiat_currency state — so the
Switch's on/off visually toggled but the form never knew, and the
conditional Fiat currency dropdown never appeared.

Rewrite around the proven pattern used elsewhere in the dialog: bind
the inputs through FormField (the shadcn-vue / vee-validate Field
component) and reach for cross-field state via useFormContext.
showCurrencyDropdown now reads form.values[allowFiatField] directly,
which mirrors the parent's actual state, and the denomination-mirror
watch goes through form.setFieldValue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TicketApiService.getCurrencies() returns 'sats' (plural) while the
schema, initialValues, and existing comparisons used 'sat' (singular)
— a pre-existing inconsistency in the events extension surface. The
new payment-rails conditionals tripped on it: as soon as the user
picked the populated 'sats' option from the price-currency dropdown,
form.values.currency became 'sats', the `=== 'sat'` check failed, and
the Fiat currency dropdown stayed hidden even with the toggle on.

Normalize all the new comparisons to accept both spellings:

- FiatToggleField: isSatDenomination(d) helper drives both the
  v-show and the auto-mirror watch.
- CreateEventDialog Zod superRefine: same accept-both rule on the
  require-fiat_currency branch.
- PurchaseTicketDialog: isPriceInSats computed drives the
  Lightning-sats badge AND the PriceConversionPreview render
  condition AND the inverse conversion watcher's bail-out.

Also flip FiatToggleField to drive dropdown visibility from the
outer FormField's slot value rather than useFormContext — slot
bindings are guaranteed reactive, sidesteps the public-form-context
indirection that earlier left allowFiat stale in the child's
template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author
Owner

Smoke-tested locally and caught two bugs — pushed b7b5a08 + 663e32e.

Bug 1 — FiatToggleField not bound to parent form. First version called useField() directly inside the child component with a reactive name getter; that created a child-local field instead of connecting to the parent form's allow_fiat state. The Switch visually toggled but form.values.allow_fiat never changed, so showCurrencyDropdown stayed false. Final structure (commit 663e32e) wraps everything in the outer <FormField name="allow_fiat"> and reads allowFiat from its scoped slot — slot bindings are reactive by construction, so no useFormContext indirection.

Bug 2 — 'sat' vs 'sats' normalization. Pre-existing inconsistency I hit by introducing the currency === 'sat' conditional. TicketApiService.getCurrencies() returns 'sats' (plural) but the schema/initialValues/comparisons in this codebase use 'sat' (singular). As soon as the user picked from the populated price-currency dropdown, form.values.currency became 'sats', the conditional failed, and the Fiat currency dropdown stayed hidden even with the toggle on. Normalized to accept both spellings everywhere the new code paths read it:

  • FiatToggleField.isSatDenomination(d) helper drives both the v-show and the auto-mirror watch.
  • CreateEventDialog Zod superRefine accepts both on the require-fiat_currency branch.
  • PurchaseTicketDialog isPriceInSats computed drives the Lightning-sats badge, the <PriceConversionPreview> render condition, AND the inverse conversion watcher's bail-out.

End-to-end standardization on one spelling tracked as a follow-up in #70 — left out of this PR to keep the fix narrow.

Smoke-tested locally and caught two bugs — pushed `b7b5a08` + `663e32e`. **Bug 1 — `FiatToggleField` not bound to parent form.** First version called `useField()` directly inside the child component with a reactive name getter; that created a child-local field instead of connecting to the parent form's `allow_fiat` state. The Switch visually toggled but `form.values.allow_fiat` never changed, so `showCurrencyDropdown` stayed false. Final structure (commit `663e32e`) wraps everything in the outer `<FormField name="allow_fiat">` and reads `allowFiat` from its scoped slot — slot bindings are reactive by construction, so no `useFormContext` indirection. **Bug 2 — `'sat'` vs `'sats'` normalization.** Pre-existing inconsistency I hit by introducing the `currency === 'sat'` conditional. `TicketApiService.getCurrencies()` returns `'sats'` (plural) but the schema/initialValues/comparisons in this codebase use `'sat'` (singular). As soon as the user picked from the populated price-currency dropdown, `form.values.currency` became `'sats'`, the conditional failed, and the Fiat currency dropdown stayed hidden even with the toggle on. Normalized to accept both spellings everywhere the new code paths read it: - `FiatToggleField.isSatDenomination(d)` helper drives both the `v-show` and the auto-mirror watch. - CreateEventDialog Zod `superRefine` accepts both on the require-`fiat_currency` branch. - PurchaseTicketDialog `isPriceInSats` computed drives the Lightning-sats badge, the `<PriceConversionPreview>` render condition, AND the inverse conversion watcher's bail-out. End-to-end standardization on one spelling tracked as a follow-up in #70 — left out of this PR to keep the fix narrow.
padreug deleted branch payment-rails-pattern 2026-05-23 21:18:54 +00:00
Sign in to join this conversation.
No description provided.