Commit graph

1,087 commits

Author SHA1 Message Date
54c1c990e1 Merge pull request 'fix(auth): don't require a Nostr pubkey to be considered logged in' (#180) from fix/auth-guard-no-pubkey-requirement into dev
Reviewed-on: #180
2026-10-08 18:46:03 +00:00
7497e6b3e5 fix(auth): don't require a Nostr pubkey to be considered logged in
isFullyAuthed() keyed the "server confirmed this session" check on
currentUser.pubkey. Every account has an id; a pubkey is optional, so
Lightning-only accounts were locked out of every strict-guard app:
login succeeded, isAuthenticated flipped true, pubkey stayed empty,
isFullyAuthed returned false, and the guard redirected back to /login --
a loop with no way out. Observed on atio, where 9 of 58 accounts have a
Nostr identity and the other 49 do not.

The pubkey was only ever a proxy for "getCurrentUser() came back", added
for issue #36 to stop a bare localStorage token counting as a session.
Keying on id preserves that protection exactly and drops the incidental
identity requirement.

Also gates the genuine requirement where it belongs: chat is Nostr
end-to-end and now opts in via installStrictAuthGuard(router,
{ requirePubkey: true }). Wallet and accounting are payment/ledger
surfaces -- per ADR-0001 (aiolabs/lnbits) LNbits is a liquidity service,
not an identity provider, so core payment flows must not be gated on
having an identity. Verified the wallet module never reads the user's
pubkey; its only nostr-tools import is nip19.encodeBytes for LNURL
bech32 in the receive QR, unrelated to identity.

Known gap, not addressed here: a pubkey-less user opening chat still
loops at /login rather than seeing an "identity required" screen. The
loop is now correct behaviour instead of a bug, but it deserves a real
message.

vue-tsc -b passes; all three call sites typecheck (the new parameter is
optional, so wallet and accounting are unchanged).
2026-10-08 20:43:47 +02:00
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
9b48ba73e2 Merge pull request 'Let the event form submit and say what is wrong' (#177) from fix/submit-gate-explains-itself into dev
Reviewed-on: #177
2026-09-29 08:02:08 +00:00
285ccc2fdb fix(events): let the event form submit and say what is wrong
The Submit button was disabled on `meta.valid`, so an organiser with a
fully filled form could be left staring at a dead button with nothing on
screen explaining it. Reported from demo: title, dates, price, capacity,
payment methods and three named ticket waves all filled, no error text
anywhere, submit greyed.

Gating on `meta.valid` is a known vee-validate foot-gun:

- disabled fields count against form validity, and the maintainer
  removed `disabled` from `<Field>` precisely because consistent
  behaviour was unachievable. This dialog disables inputs while loading
  and renders `fiat_currency` conditionally, so it is exposed.
- `meta.valid` is documented to go stale — false with zero errors after
  a form is re-shown (logaretm/vee-validate#4630).
- it is false before the form has ever validated, which is why the docs
  suggest `valid && dirty` rather than `valid` alone. A flag that is
  unreliable in both directions should not be the only thing between an
  organiser and their event.

So the button is now disabled only while a submit is genuinely in
flight, and the reasons come to the user instead:

- `handleSubmit`'s invalid callback names the offending fields in a
  toast ("Check Tickets, Start date, Title"), opens whichever collapsible
  holds one, and scrolls the first into view. Previously a FormMessage
  inside a closed section was invisible.
- promo codes and ticket waves are not in the Zod schema, so
  vee-validate cannot block on them; they are checked in the submit
  handler, which opens their section rather than failing silently. Both
  editors live inside collapsibles, so this was the same trap.

Verified in a browser: a blank form now has an enabled button, and
clicking it reports "Check Tickets, Start date, Title".

Refs aiolabs/events ticket-wave work; the report came from testing #176
on demo.
2026-09-29 10:01:22 +02:00
d9de0fdd73 Merge pull request 'Ticket waves: manage them, buy from them, stop lying about totals' (#176) from feat/ticket-waves into dev
Reviewed-on: #176
2026-09-29 07:23:44 +00:00
66e88ce0f8 docs: how to actually run a standalone app locally
`npm run dev` serves the hub only. Visiting /events under it returns a
500 — `src/events-app/main.ts` imports `virtual:pwa-register`, which
only exists once the PWA plugin from `vite.events.config.ts` loads — and
the page renders an empty body, so it presents as a broken route or a
relay-sync problem rather than a missing plugin. Cost a while to work
out while testing ticket waves; `npm run dev:events` is the answer.

Also records what the next person will hit straight after: a standalone
app routes only `/`, `/login`, `/settings`, so an event detail opens by
clicking its card rather than by navigating to `/<event-id>`; the
purchase endpoint rejects a dev-server `frontend_url` that is not on the
instance's CORS allow-list (correctly — `_resolve_frontend_root` fails
closed so a buyer is never emailed a link into the wrong app); and the
login page opens on its "Demo Account" tab.

And a warning that CLAUDE.md is not prettier-managed: `prettier --write`
on it rewrites ~90 lines of embedded TypeScript samples. Nearly shipped
that here.

Docs-only, so direct to dev per the tooling carve-out. Not committed to
main with a dev rebase as that rule's pattern suggests: main is an
ancestor of dev by 181 commits, so rebasing would rewrite every one of
them and force-push a shared branch with PR #176 open off it. This
reaches main on the next dev -> main fast-forward instead.
2026-09-29 09:22:58 +02:00
072ca8817b fix(events): stop showing a ticket total nothing publishes
The card and detail page rendered "{available} of {total} tickets left",
where total was derived as `available + sold`. That was already wrong
after any organizer edit re-baselined remaining (#143), and waves make
it meaningless: since events ext v1.6.8 `tickets_available` is the
ACTIVE WAVE's stock while `tickets_sold` counts every wave, so the two
no longer share a denominator. An event with 5 left in the open wave and
7 sold across earlier ones displayed "5 of 12".

Nothing publishes a capacity — no tag, no field — so nothing can honestly
show one. Both sites now use the existing `ticketsAvailable` string,
which says only what the data knows.

`EventTicketInfo.total` and the `ticketsRemainingOfTotal` string are
removed rather than left unused, so neither can be picked up again.

Closes #143. Whether to publish a real capacity remains open on
aiolabs/events#34, which narrowed it to either dropping the total (this)
or storing capacity and publishing an explicit `tickets_total` tag.
2026-09-29 08:30:03 +02:00
d55115d0ac feat(events): manage ticket waves from the organizer form
Capacity, price, currency and fiat edits made in the webapp were
silently discarded. Since events ext v1.6.8 the backend derives those
event-level fields FROM the waves on every write, so submitting a
changed `amount_tickets` beside an unchanged wave list left the wave's
old number winning. Verified against a running instance before fixing:
999 / 77 went in, 45 / 10.0 came back, HTTP 200.

The dialog's price / capacity / currency fields ARE the primary wave, so
they are now written into it on submit — the same write-through the
LNbits admin dialog performs. A new "Ticket waves" section manages the
tiers after the first, mirroring PromoCodesEditor: v-model over a row
array, with the pure `validateWaveRows` so submit gates on exactly the
rules the backend enforces.

Validation follows `_validate_wave_capacity`, including its subtlety: a
wave already stored on the event may sit at zero capacity, because that
is what sold out looks like and rejecting it would make a sold-out event
uneditable. Only a newly added wave must state a real capacity. Stored
zeroes render as "Sold out" rather than as an error.

One hazard found while wiring the write-through: on CREATE there is no
stored event, so the implied primary wave was synthesized with empty
date strings — which the backend then throws on for every read
(`ValueError: Invalid isoformat string: ''`). The seed now supplies the
same inputs `create_event` uses, so the wave the webapp sends matches
the one the backend would have built. Pinned by a test.

72 tests; vue-tsc and prettier clean.
2026-09-29 08:28:48 +02:00
afa34c90c2 feat(events): buy from a chosen ticket wave
Checkout was priced and requested off the event, which since events ext
v1.6.8 means the PRIMARY wave. Two things were wrong at once: once early
bird closed the dialog displayed the closed tier's price while the
backend charged the open one, and an event with two waves open could not
be bought at all — the purchase endpoint refuses to guess ("Please
select a ticket wave") and nothing sent `ticket_wave_id`.

The dialog now resolves a wave the way the backend does: a single open
wave is implied, several means the buyer picks, none means nothing is on
sale. Price, currency and fiat availability all read from that wave —
`allow_fiat` too, which is a per-wave opt-in and could otherwise offer a
rail the purchase would refuse. The picker only renders when there is an
actual choice, so the common single-wave case gains no extra click, and
the CTA is disabled rather than firing a request the backend will reject.

`ticket_wave_id` now rides on all three purchase paths (discounted-free,
Lightning, fiat) and on the promo preview, so the quote and the charge
are priced against the same tier.

Plumbing worth noting: the tags a card renders from describe the active
wave but carry no wave id, by the flat-tag decision on aiolabs/events#61.
So the detail page fetches the LNbits record for the pickable tiers —
possible only because `ticket_waves` is public from v1.6.8-aio.2
(aiolabs/events#66). Non-fatal on failure: the backend answers 410 for
sold-out or closed events, where there is no tier to pick anyway.

3 service tests pin the wire format, including that no empty wave id is
sent when the buyer had no choice. 62 pass; vue-tsc and prettier clean.
2026-09-29 08:23:52 +02:00
8490cdf84c feat(events): ticket-wave types and arithmetic
Foundation for wave support. Types only plus a pure lib — no UI yet.

Since events ext v1.6.8 price, currency and stock belong to a ticket
WAVE, and the event-level fields are derived: `amount_tickets` is the sum
across waves, `price_per_ticket` and `currency` are the FIRST wave's.
Reading them to price or count anything is now a bug, which is what the
webapp does today.

`lib/ticketWaves.ts` mirrors the extension's `ensure_ticket_waves`,
`get_active_ticket_waves`, `advertised_ticket_wave` and
`sync_event_ticket_waves`, plus the `_resolve_ticket_wave` selection rule
and the primary-wave write-through the LNbits admin dialog performs.

Every function was cross-checked against the running Python with shared
fixtures rather than written from the docs, which caught one divergence
worth recording: the backend keeps the time component on a synthesized
primary wave's `closing_date`, and `sync_event_ticket_waves` takes a
STRING max over those. Truncating to a date — the obvious-looking
thing, and what this first did — makes the client derive a different
event closing_date than the server. Truncation belongs at comparison
time, in `waveDay`, mirroring the backend's `_parse_date`.

`ticket_wave_id` added to CreateTicketRequest and PromoValidateRequest;
`ticket_waves` to EventExtra. The latter is public from events ext
v1.6.8-aio.2 (aiolabs/events#66) — without that a buyer cannot learn a
wave id at all.

19 tests. vue-tsc and prettier clean; 59 events tests pass.
2026-09-29 08:19:33 +02:00
9ad8d42e58 Merge pull request 'fix(events): stop subtracting sold from amount_tickets' (#174) from fix/my-events-availability-arithmetic into dev
Reviewed-on: #174
2026-09-27 21:09:34 +00:00
b46cdf393c fix(events): stop subtracting sold from amount_tickets
`amount_tickets` IS the remaining count — the backend decrements it on
every sale and increments `sold`, so the two describe the same tickets.
MyEventsPage subtracted one from the other, removing each sale twice.

Because remaining + sold is the original capacity, `amount_tickets <=
sold` first becomes true at exactly the halfway point. So the purchase
button disabled itself once an event had sold half its seats, and
"Tickets Available" under-reported all the way there.

The backend half of this shipped as aiolabs/events#59 in v1.6.1-aio.17,
which makes the mismatch worse rather than better: the server now sells
correctly while the webapp refuses to let anyone buy. On aio-demo three
events are past the threshold — "Tech Meetup" has 9 tickets left, shows
-2, and the button is dead. The API confirms they are sellable.

Also makes ticket capacity a required field, at least 1. A capacity of 0
used to mean "unlimited", but nothing on the backend agreed: both
api_get_event and api_ticket_create read `amount_tickets < 1` as sold
out, so a 0 produced an event that advertised unlimited tickets on the
card and returned 410 to every purchase. aiolabs/events#62 stops
publishing that lie; this stops the form creating it.

The payload check moves from falsy to `!== undefined`, which is what
unblocks the backend making the field required — a falsy check dropped
0 from the request entirely, so a server-side `ge=1` would have 422'd
this dialog for precisely the case users were picking.

Editing an existing zero-capacity event now opens with the field empty
rather than prefilled with 0, so the organiser has to choose.

Closes #173
2026-09-27 23:08:59 +02:00
8203c54feb Merge pull request 'fix(core): read the routing fee from fee, not the absent fee_msat' (#171) from fix/fee-field-name into dev
Reviewed-on: #171
2026-09-26 12:37:30 +00:00
21d4e0e4df fix(market): declare feeMsat on Order so the persisted fee is typed
`useLightningPayment` writes feeMsat onto the paid order, but `Order`
never declared it. `updateOrder` takes `Partial<Order>`, and the write
goes through an inferred variable, so the excess-property check never
fired — the value landed in the store invisible to the type system and
unreadable by any consumer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 14:26:29 +02:00
7d4cb3d49d fix(core): read the routing fee from fee, not the absent fee_msat
Closes #170.

LNbits payment payloads carry `fee`, signed millisats like `amount`.
There is no `fee_msat` field, so three modules read `undefined`:

  PaymentService.PaymentResult declared `fee_msat: number` — a field the
  API never sends — so the compiler vouched for a number that was always
  undefined, and the lie propagated to every consumer.

  market/useLightningPayment persisted `feeMsat: undefined` onto paid
  orders (on a property the Order type does not even declare).

  events/LnbitsPaymentProvider had `data.fee_msat ?? 0`, whose fallback
  masked the miss so every payment recorded a zero fee.

`payInvoice` now normalizes at the boundary instead of returning the raw
payload: it maps `fee` to a positive `feeMsat`. Doing it once there means
no call site can pick the wrong field name or forget that outgoing
amounts are signed. `PaymentResult.feeMsat` replaces the phantom field,
so the type finally matches what callers receive.

Verified against a live pay-invoice response, plus a synthetic payment
carrying a real routing fee (internal LNbits transfers are fee-free, so
the live case cannot exercise a non-zero fee):

  live response       fee present: True   fee_msat present: False
  synthetic -1234 msat
    old (events)  -> 0      fee silently lost
    old (market)  -> undefined
    new (both)    -> 1234   positive magnitude

Nothing read these values downstream, so no totals were miscomputed; the
fee was simply recorded as missing or zero.
2026-09-23 09:54:09 +02:00
3e5eeaf7a9 Merge pull request 'fix(wallet): gate the received-payment toast on status, not absent pending' (#169) from fix/wallet-payment-toast-guard into dev
Reviewed-on: #169
2026-09-22 21:16:06 +00:00
8438e9f26d Merge pull request 'fix(wallet): narrow the bolt11 amount section so the build passes' (#168) from fix/wallet-bolt11-section-narrowing into dev
Reviewed-on: #168
2026-09-22 21:15:59 +00:00
18304984ce fix(wallet): gate the received-payment toast on status, not absent pending
Last remaining reader of the phantom `pending` field, same root cause as
#161: it is a pydantic `@property` and never appears in the payload, so
`!payment.pending` was always true. The guard reduced to `amount > 0`
alone and never actually suppressed an unsettled payment, which is what
it was there to do. Now checks `status === 'success'`.

The nested `else` that toasted "Sent N sats" was unreachable: the
enclosing condition already required `amount > 0`. Dropped rather than
repaired, because a send made from this app is already confirmed by
SendDialog's own success toast and re-announcing it here would double
up. An outgoing payment made from another client on the same wallet
still lands in the history list; it just does not raise a toast.

Verified against a live settled payment over the websocket:

  amount=44000  status=success  pending key present=False

Found while checking that the six merged wallet PRs behaved correctly
together.
2026-09-22 23:11:25 +02:00
f444677872 fix(wallet): narrow the bolt11 amount section so the build passes
`dev` does not build. This is fallout from #162: `vue-tsc -b` rejects
`section.value` with

    error TS2339: Property 'value' does not exist on type 'Section'.
      Property 'value' does not exist on type '{ name: "checksum"; letters: string; }'.

`Section` is a discriminated union and its "checksum" member carries
only `letters`. The predicate was written as `(s: any) => ...`, which is
not a type guard, so `find` returned the whole union and `.value` could
not resolve against every member.

Replaced with a real type predicate narrowing to the `amount` member.

My fault for how I verified #162: I ran `vue-tsc --noEmit`, but the
build script runs `vue-tsc -b`, which uses the project-reference config
and is stricter. The two disagree, so `--noEmit` alone is not evidence
that the build passes. #166 did run a full build, but it was branched
off #165 and so never contained this code.

Amountless behaviour is unchanged, re-verified after the narrowing:
  amountless BOLT11 -> 0        (amount_msat sent)
  live 777-sat       -> 777000  (amount_msat omitted)
  malformed          -> null    (defer to server)

`npm run build:wallet` now succeeds.
2026-09-22 23:09:34 +02:00
7308292642 Merge pull request 'feat(wallet): denominate invoices in any currency the server accepts' (#166) from feat/wallet-fiat-invoices into dev
Reviewed-on: #166
2026-09-22 21:06:39 +00:00
682e1964bc Merge pull request 'fix(base): resolve the sats key in price conversions' (#165) from fix/price-conversion-sats-key into dev
Reviewed-on: #165
2026-09-22 21:06:27 +00:00
586698259a Merge pull request 'fix(wallet): refetch balance and history on resume' (#164) from fix/wallet-resume-refetch into dev
Reviewed-on: #164
2026-09-22 21:06:18 +00:00
717e0bd8ae Merge pull request 'fix(wallet): wire websocket config through module install' (#163) from fix/wallet-websocket-config into dev
Reviewed-on: #163
2026-09-22 21:06:10 +00:00
cfc4e80e24 Merge pull request 'fix(wallet): send amount_msat when paying an amountless invoice' (#162) from fix/wallet-amountless-invoice into dev
Reviewed-on: #162
2026-09-22 21:06:00 +00:00
05d2797a92 Merge pull request 'fix(wallet): read payment status from status, not the unserialized pending' (#161) from fix/wallet-payment-status-mapping into dev
Reviewed-on: #161
2026-09-22 21:05:44 +00:00
5ab944bd42 feat(wallet): denominate invoices in any currency the server accepts
Receiving was sats-only. An invoice can now be quoted in any currency
the LNbits instance allows (~165 by default, narrowable via
`LNBITS_ALLOWED_CURRENCIES`), which is what a merchant pricing in EUR
actually needs.

LNbits does the conversion: `unit: "EUR"` with `amount: 5.50` yields an
ordinary BOLT11 invoice for the equivalent sats and records the original
denomination on the payment's `extra` (`fiat_currency`, `fiat_amount`,
`fiat_rate`). Nothing about the payment rail changes — the payer still
settles over Lightning.

- `useCurrencies` (base module) fetches the currency list and the
  instance default once per process, de-duplicating concurrent callers.
  It sits beside the other shared payment primitives because four
  modules already duplicate this same `getCurrencies()` call; they can
  adopt it later.
- Decimal places come from `Intl.NumberFormat`, not a hardcoded table,
  so the input step is 0.01 for EUR and 1 for JPY, which has no minor
  unit. Amount validation tracks the unit too.
- Switching unit clears a typed amount. Reinterpreting "10" from sats to
  EUR would silently create an invoice for a wildly different value.
- The created invoice leads with what the payer was quoted (€5.50) and
  shows the sat amount as secondary, noting the rate was fixed at
  creation.
- A live "≈ N sats" preview uses the shared conversion composable. It is
  best-effort: a failed lookup renders nothing and never blocks
  creation, since LNbits converts authoritatively server-side.
- Sats stays the default, and the selector is hidden entirely if the
  currency list is unavailable, so the wallet still works if the rate
  service is down.

Verified in a headless browser against the live LNbits:

  selector lists 166 options (sats + 165 currencies)
  EUR 5.50 -> preview "≈ 7,301 sats" -> POST {amount: 5.5, unit: "EUR"}
           -> invoice lnbc73010n... (7301 sats), shown as "€5.50"
              with "Payable as 7,301 sats"
  sats 250 -> POST {amount: 250, unit: "sat"}, no fiat line
  JPY step/min 1; EUR step/min 0.01
  switching EUR -> JPY clears the typed "12.34"

Fiat in the transaction history is deliberately left out; it applies to
all payments rather than just newly created ones, and is tracked
separately.
2026-09-22 22:59:35 +02:00
45710447e1 fix(base): resolve the sats key in price conversions
Every fiat-to-sat conversion returned null. `convert()` looked up the
response by the `to` key it had passed in, but LNbits does not echo that
key: it always names the satoshi amount "sats" (plural), whatever the
caller asked for. So `data["sat"]` missed and the function fell through
to its null path.

    POST {from_: "EUR", to: "sat", amount: 5.50}
      -> {"EUR": 5.5, "sats": 7298, "BTC": 7.298e-05}

This silently broke the one shipped caller that converts in that
direction: `PurchaseTicketDialog` computes `lightningSats` via
`convert(amount, currency, 'sat')`, so it was always null.
`PriceConversionPreview` has a `to === 'sat'` formatting branch that
could never be reached either.

Key selection moves into `pickConverted`, which maps sat/sats onto the
plural key the server actually uses and keeps the previous fallbacks.
The reverse direction (sat to fiat) already worked and is unchanged.

Verified against the live LNbits:

  EUR -> sat (5.50):  old=null   new=7299     FIXED
  USD -> sat (10):    old=null   new=11588    FIXED
  JPY -> sat (1000):  old=null   new=7360     FIXED
  sat -> EUR (7295):  old=5.4969 new=5.4969   unchanged
2026-09-22 22:51:13 +02:00
9e7fb0a0f1 fix(wallet): refetch balance and history on resume
A payment that settled while the app was backgrounded stayed invisible
until the user pressed Refresh by hand.

`onPause` closes the socket to save battery, and the LNbits websocket
only pushes on live events — it replays nothing on reconnect. `onResume`
reconnected the socket and stopped there, so the notification for
anything that settled in between reached no one. Balance and the
transaction list both sat at their pre-pause values.

`onResume` now pairs reconnection with an explicit refetch of both: the
balance via PaymentService and the rows via WalletService. Neither
implies the other, so both are needed. `allSettled` keeps a failure in
one from leaving the other un-refreshed.

The balance fetch is extracted from the polling fallback rather than
duplicated, so the two paths cannot drift.

Verified against the live LNbits with a real internal payment:

  socket closed (backgrounded)
  paid a 33-sat invoice, waited 5s so the notification task fired
  reconnected -> nothing arrived in 8s; the 33 sats were invisible
  REST refetch -> 283633 sat, a 33 sat delta the UI had missed

A first attempt reconnected immediately and did see a message. That was
a test artifact, not a replay: LNbits dispatches the notification from
an async task, which found the new socket. Hence the deliberate delay
above, which is what a backgrounded app actually looks like.
2026-09-22 22:46:38 +02:00
a62af8bd16 fix(wallet): wire websocket config through module install
Every websocket setting in `app.config.ts` was ignored. The service read
`(window as any).appConfig`, which nothing in the codebase ever assigns,
so the lookup was always undefined and the service silently ran on its
own hardcoded defaults — including `VITE_WEBSOCKET_ENABLED`, which could
not actually disable the websocket.

The config was already being passed correctly: `src/wallet-app/app.ts`
registers the module with `appConfig.modules.wallet`, and the plugin
manager forwards it as `install(app, { config })`. The service just
wasn't reading from there. It now takes the config through its
constructor, merged over an exported `DEFAULT_WEBSOCKET_CONFIG`.

Using install options rather than importing `@/app.config` directly
matters here: the hub config no longer declares a wallet module at all
(the wallet ships only as a standalone PWA), so a direct import would
resolve to a config with no wallet section.

Verified in the built bundle: `maxReconnectAttempts:3` from
`src/wallet-app/app.config.ts` is present alongside the default of 5,
and no `window.appConfig` read remains.
2026-09-22 22:44:26 +02:00
d636be6bb5 fix(wallet): send amount_msat when paying an amountless invoice
Paying a zero-amount BOLT11 always failed. The send dialog correctly
showed an amount field for such an invoice, but the amount the user
typed was then dropped: the request body carried only `bolt11`.

LNbits rejects that outright — `_validate_payment_request` raises
"Amount required for amountless invoices." when the invoice has no
amount and no `amount_msat` accompanies it. So the user filled in an
amount and got an error.

The invoice is now decoded in the service rather than trusted from the
caller, so every caller benefits and no call site has to remember. When
the decoded amount is zero we forward the caller's amount as
`amount_msat`; when the invoice carries its own amount we deliberately
send nothing, since the invoice amount is authoritative and must not be
overridable by the caller.

A decode failure returns null and sends nothing, leaving the server as
the authority on whether the invoice is payable.

Verified with light-bolt11-decoder:
  BOLT11 spec donation vector (amountless) -> 0      (amount_msat sent)
  live 777-sat LNbits invoice              -> 777000 (amount_msat omitted)
  malformed input                          -> null   (defer to server)
2026-09-22 22:42:28 +02:00
9c9a29f93a fix(wallet): read payment status from status, not the unserialized pending
Every payment in the history list rendered as "confirmed" — pending
invoices looked settled and failed payments looked successful. The
receive dialog's "Paid" indicator inherited the same defect.

`Payment.pending` is a Python `@property` on the LNbits model, and
LNbits pins pydantic 1.x, which never serializes properties. The field
is therefore absent from every REST and WebSocket payload, so
`payment.pending` was always `undefined` — falsy — and the ternary fell
through to "confirmed" for every row.

Verified against a live LNbits instance: the payload carries `status`
("pending" | "success" | "failed") and no `pending` key. A freshly
created, unpaid invoice now maps to "pending" where it previously
mapped to "confirmed".

The same drift hid a second bug: the WebSocket mapper read `fee_msat`,
which does not exist either. The field is `fee`, signed millisats like
`amount`, so live-added rows never showed a fee.

Both mappers existed as near-duplicates that had diverged, which is how
the two fields fell out of sync in the first place. `loadTransactions`
now delegates to the single shared mapper.
2026-09-22 22:40:38 +02:00
cc2adcb808 Merge pull request 'feat(chatelet): let a guest resume a held-but-unpaid booking' (#160) from feat/chatelet-resume-pending-invoice into dev
Reviewed-on: #160
2026-09-21 17:58:24 +00:00
6a0f1c820a Merge pull request 'fix(chatelet): root-relative routes so room deep links and refreshes work' (#159) from fix/chatelet-standalone-routing-base into dev
Reviewed-on: #159
2026-09-21 17:58:09 +00:00
53327b3ef2 fix(chatelet): root-relative standalone routes for clean room URLs
The chatelet module hardcoded "/chatelet" in its route paths while the
standalone also mounts under a Vite base path ("/chatelet/"), which the
router applies too. The two stacked, so in-app URLs came out
double-prefixed ("/chatelet/chatelet/<id>") and the clean single-prefix
URL ("/chatelet/<id>" — what a person would type or share) matched nothing
and fell back to the room list.

Click-through and refresh already worked on the double-prefixed URL via the
standalone SPA fallback (server-deploy d5f5a19); this is about the URLs
themselves. Make the routes root-relative ("/" list, "/:id" detail,
"/my-bookings") so the base path supplies the single prefix: in-app nav now
produces clean "/chatelet/<id>" URLs, and that shareable form resolves to
the room.

Drop the now-redundant "/" -> "/chatelet" redirect, point the
list/detail/browse links and the Rooms tab at the new paths, and key the
tab's active state off the route name (a "/" prefix test would match
everything).

Verified on a production build served under "/chatelet/" like nginx: the
list, a deep link to a room, a refresh on it, and in-app room clicks all
resolve to the detail page with a single-prefixed URL. vue-tsc build passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 19:51:22 +02:00
8d9a31ca1c feat(chatelet): let a guest resume a held-but-unpaid booking
Closing the invoice dialog left the guest stranded: the hold blocks the
room until it expires, and the public booking read-back has no bolt11, so
there was no way back to the invoice — they had to wait out the hold to try
the room again.

Persist each hold's invoice on the device when it is created
(usePendingBooking, localStorage keyed by room, one per room, pruned on
expiry). On the room page, if a live hold exists for it (verified via the
public booking endpoint), show a 'Resume payment' banner that reopens the
invoice from the saved bolt11 and resumes the settlement poll. The saved
entry is cleared when the booking confirms, the hold lapses, or it expires.
Works without an account — the store is the browser's, not the server's.

Verified headlessly against a real hold: the banner shows for a live hold
and resumes to the invoice (QR + amount); an expired hold shows nothing and
is pruned. vue-tsc build passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 15:04:06 +02:00
13dd3fde59 Merge pull request 'fix(chatelet): let a min-stay range extend forward (reka span guard)' (#158) from fix/chatelet-min-nights-range-spanning into dev
Reviewed-on: #158
2026-09-21 12:32:09 +00:00
6f56e999d6 fix(chatelet): let a min-stay range extend forward (reka span guard)
The stay picker enforced min_nights by disabling the days between check-in
and check-in + min_nights. reka-ui refuses to *complete* a range that spans
a disabled day, so on a 2-night-minimum room every forward check-out was
unreachable: the guest picked a check-in and could then only click an
earlier day, which reka swapped into the range (the 'calendar jumps
backwards' report). One-night-minimum rooms were unaffected, which is why
the Orangery Suite worked and the Tour Room didn't.

Stop disabling the in-window days. Enforce the minimum in onModel instead:
a too-short range keeps the check-in and clears the check-out, resetting
reka's controlled model directly (checkOut is already '' so the props watch
won't fire, and reka's highlighted end would otherwise linger). Occupied
nights past the next booking still cap the range, and #157's check-in
validity guard is unchanged.

Verified headlessly on min-1 and min-2 rooms, empty and with a blocked
night: forward selection completes, exact-minimum completes, below-minimum
is rejected, the boundary check-out is reachable, and the quote is correct.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 13:28:35 +02:00
fa7436a499 Merge pull request 'fix(chatelet): don't offer a check-in that can't fit a minimum stay' (#157) from fix/chatelet-stay-picker-checkin-validity into dev
Reviewed-on: #157
2026-09-21 09:57:42 +00:00
d8141b4485 fix(chatelet): don't offer a check-in that can't fit a minimum stay
The stay picker disabled only occupied nights when choosing a check-in, so
a free day with an occupied night closer than min_nights was still
selectable — e.g. min 2 nights with the night after next booked. Picking it
left every later day disabled (no legal check-out), so reka's only move was
an earlier day, which it silently swapped into the range: the guest picks a
date and the calendar 'jumps backwards'. Disable such a day up front: a
check-in is valid only if the minimum stay fits before the next occupied
night and within the loaded window.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 11:55:51 +02:00
42d7eaaadd Merge pull request 'feat(chatelet): stay calendar, room gallery + house rules, My bookings' (#156) from feat/chatelet-my-bookings into dev
Reviewed-on: #156
2026-09-21 09:12:06 +00:00
8a6a8c08be Merge pull request 'feat(hub): Chatelet tile' (#155) from feat/chatelet-hub-tile into dev
Reviewed-on: #155
2026-09-21 08:30:09 +00:00
826b813bc1 feat(hub): Chatelet tile (VITE_HUB_CHATELET_URL) + chatelet in flake app list
Ninth chakra tile, lit when a deploy sets VITE_HUB_CHATELET_URL (server-deploy's
webapp-standalones module does so when the chatelet standalone is enabled).
Also registers chatelet in flake.nix's app list so `nix build .#chatelet`
exercises the standalone builder like the others.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 09:54:42 +02:00
3c6954f144 feat(chatelet): My bookings page and bottom navigation
/my-bookings (auth-required, mirrors events' /my-tickets) lists the
signed-in guest's stays from the extension's new GET /bookings/mine
(chatelet ≥ v0.5.0): upcoming and past, room title and photo joined from
the public room list, dates and nights, guest count, amount, and a status
badge — unpaid holds show until when the hold lasts. The standalone app
gains its first bottom tabs, Rooms and My bookings, the latter ghosted
with a log-in toast when signed out; the booking confirmation links there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:19:56 +02:00
fb0478c028 feat(chatelet): photo gallery and house rules on the room page
Swap the single hero image for the shared ImageViewer (thumbnails,
lightbox and cycle controls when a room has more than one photo, as the
marketplace product page does) and add a 'Things to know' card: check-in
from / check-out by, guest cap, minimum stay and the cancellation policy.
The rules come from the room owner's operator settings (chatelet ≥ v0.5.0)
so the card only renders when the extension sends them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 12:17:43 +02:00
7dbabfaa81 feat(chatelet): availability calendar with Airbnb-style stay picking
Replace the two date pickers and the manual 'Check availability' button
with one range calendar fed by the extension's keyless unavailable-ranges
endpoint (chatelet ≥ v0.5.0): occupied nights are struck through, a stay
can never straddle one, and check-out may land on the morning another
guest arrives (half-open nights). Two months on sm+, one on phones.

reka-ui only guards clicks with isDateDisabled and hides selected
styling on isDateUnavailable days, so the matchers are selection-aware:
once a check-in is chosen the first occupied night after it stops being
unavailable (it is the last legal check-out) and every later day is
disabled; min_nights is enforced the same way. Both matchers close over
refs because reka reads them once at setup.

Picking a complete stay quotes it automatically; the quote card shows
rate × nights and the total. A quote that contradicts the local calendar
refreshes it (someone else's hold appeared), as does a confirmed booking.
The /login?redirect round-trip of dates is unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:53:22 +02:00
6ea94692a1 feat(ui): shadcn range-calendar primitive over reka-ui RangeCalendarRoot
Port of ui/calendar onto reka-ui's RangeCalendar* parts, same slot shape.
The cell trigger gains range states — accent tint for the nights inside
the range, primary pills on the two ends — and a real unavailable look
(struck through, muted, not a pointer target).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:53:22 +02:00
ded2fd10f8 Merge pull request 'feat(chatelet): book a room and pay by Lightning (slice 2)' (#154) from feat/chatelet-booking into dev
Reviewed-on: #154
2026-09-15 21:52:07 +00:00