Closes the last multi-currency gap before v0.2.0:0 cutover. Operators
who created a product in one currency can now switch to another via
the Edit modal — no need to disable + recreate.
Backend:
- PATCH /v1/admin/products/:id accepts price_currency + price_value
alongside the legacy price_sats. Same validation shape as the
create endpoint (whitelist SAT|USD|EUR, mismatched legacy + typed
→ 400).
- repo::update_product_with_currency replaces the SAT-only
update_product as the canonical entry; the SAT-only function is
now a thin wrapper that always passes "SAT". For SAT updates,
price_sats and price_value are dual-written. For fiat updates,
price_sats is reset to 0 — gets repopulated by the rate fetcher
on the next invoice creation against the product.
Frontend (Products → Edit modal):
- Currency picker dropdown next to the price input. Initial value
reads from the product's current currency.
- For fiat products, the displayed price renders as decimal main
units ($49.00); save converts to cents on the way out.
- Hint text + step swap as the operator changes currency.
- Doesn't auto-clobber the displayed value when currency changes
— operator decides if the same number still makes sense.
No schema changes (column shape from migration 0010 is sufficient).
Test count unchanged at 38 — pure handler + UI work, behavior
covered by the existing currency tests on create.
Analytics opt-in (Overview page):
- Replaces the prominent "Help improve Keysat" card with a compact
one-line strip below the public-key card. Single sentence + native
checkbox + "what gets sent?" link that toggles an inline disclosure.
- Auto-saves on toggle (no separate Save button) so the affordance
reads as "click it and it's done", not as a multi-step form.
- Default remains OFF — the right call for Keysat specifically given
the product positioning around sovereignty / no phone-home.
- Inverted-checkbox UX bug fixed (was rendering "☑ Disabled" which
reads as a double-negative and confused operators).
- Reset install_uuid moves into the expanded view as a small "reset"
link rather than a prominent button.
Discount-code create form:
- New Currency picker dropdown next to Amount (SAT default, USD,
EUR). For 'percent' the currency is recorded for audit but
amount remains basis points; for 'fixed_sats' / 'set_price'
the currency determines the unit (sats for SAT-currency,
cents for USD/EUR).
- Decimal entry on USD/EUR ($9.99) converts to cents on the way out.
- Hint text + step attribute swap live as the operator changes
Kind or Currency.
- Discount-code list cell now formats fiat amounts as "$10.00 off"
/ "€25.00 flat" with cents-to-main-unit conversion. Existing
SAT codes render unchanged.
Buy page tier picker (JS + server render):
- Tier cards' static HTML now respects product.price_currency:
USD products render as "49.00 USD" instead of "0 sats" (which
was happening for fiat-priced products since price_sats=0 for
those).
- TIERS JSON embedded in the page now carries (price_currency,
price_value) alongside the legacy price_sats. JS selectTier()
reads the right fields and swaps the unit cell ("sats" ↔ "USD")
in addition to the amount when the buyer clicks a different tier.
- formatTierPrice() helper centralizes the SAT-vs-fiat rendering;
free-tier detection checks the value in the relevant unit.
build_tiers_json() also wired to pass currency through. Per-policy
currency override stays NULL = "inherit from product" until v0.3
admin UI lands.
Test count unchanged at 38 (this is purely SPA + buy-page render
work; behaviour is covered by existing API tests).
Closes the last T2 plan item. Off by default; toggling on requires
the operator to confirm a collector URL (an empty URL is "armed but
silent"). The toggle lives on the admin Overview page next to the
public-key card — the right place for a privacy-affecting choice
since it's where operators actually live.
What's sent (per the in-card "Show me exactly what gets sent"
disclosure, and pinned by the test):
- install_uuid: random UUIDv4 generated on first opt-in. NOT
derived from operator_name, store id, public URL, or any
other identifier. Wipeable via the Reset button.
- daemon_version (CARGO_PKG_VERSION).
- tier (creator/pro/patron/unlicensed) — the same string the
admin tier endpoint already exposes.
- counts: products, active_licenses, settled_invoices — each
floored to the nearest 5 (anti-fingerprinting; an exact license
count uniquely identifies an operator over time).
- uptime_bucket: <1d / 1-7d / 1-4w / >4w (bucketed, not exact).
What's NOT sent (test asserts none of these strings appear in the
preview heartbeat): operator_name, public_url, store_id, api_key,
buyer_email, btcpay_url. Also no product/policy slugs or names, no
license/invoice ids, no fingerprints, no webhook secrets.
Backend:
- src/analytics.rs — heartbeat builder, opt-in check, daily
background tick (5min initial grace period after boot).
- src/api/community.rs — GET / POST / reset admin endpoints.
- main.rs spawns the background tick unconditionally; the tick
is a no-op if disabled OR no collector URL configured.
Frontend (web/index.html, Overview page):
- Toggle + collector URL input + privacy disclosure showing the
EXACT JSON shape that would be sent (renders the live preview
heartbeat from /v1/admin/community-analytics).
- "Reset install_uuid" button so an operator who's been beaconing
under one identifier can start fresh.
Also includes the configureBtcpay.ts idempotency change from
v0.1.0:46 (already committed; touched again here only because the
diff includes the .ts file in the same dirty-tree push).
Test count: 32 (was 31; +1 community_analytics_opt_in_and_privacy_contract
which seeds 23 licenses and verifies the heartbeat reports 20 —
proves the floor-to-5 anti-fingerprinting is in effect).
The /v1/admin/webhook-deliveries endpoints from v0.1.0:43 were
operator-actionable via curl but invisible in the dashboard. Adds a
"Delivery history" section to the Webhooks page showing recent
deliveries with a status filter (defaults to "Failed (DLQ)" so the
problem case is what an operator sees first).
Each row shows created-at, event type, status badge (delivered /
failed / pending), attempt count, last status code, and last_error
inline beneath the status when present (so operators don't have to
chase a separate "details" view to know why a delivery failed).
Non-delivered rows get a Retry button that re-queues via the
existing POST /v1/admin/webhook-deliveries/:id/retry; the worker
picks up the retried row on its next 5s tick.
No backend changes. The endpoints landed in :43; this commit is
just the front-end surface.