Closes the operator surface for tier upgrades. With this in,
operators have a complete UI for managing the upgrade ladder
without ever needing the curl-the-API path.
Policy editor (create + edit forms):
- New "Tier ladder rank (optional)" number input alongside the
recurring section. Operators set "0" for free, "1" for
standard, "2" for pro, etc. Empty input = "not in any ladder"
(server stores NULL; that policy is excluded from buyer-facing
upgrade flows but admin can still force-change to/from it).
- Edit-form behavior: empty input clears tier_rank to NULL.
Filled input sets to that value. The PATCH always sends the
field (using the nullable-patch shape Some(Option<i64>)) so
the operator's intent — clear or set — actually lands.
- Range 0–1000 enforced server-side; clipped client-side too.
Licenses page:
- New "Change tier" button on every non-revoked license row,
to the left of Suspend/Unsuspend/Revoke.
- Opens a modal that:
* Loads all policies for the license's product
* Shows them in a dropdown with metadata (rank · cadence ·
trial flags) so the operator can see the ladder shape
* Offers a "Apply as comp (skip_payment=true — no invoice,
flips immediately)" checkbox + an audit-reason field
* On submit, POSTs to the new admin endpoint:
- skip_payment=true → "Applied" status, modal closes
- skip_payment=false → renders the checkout URL the
operator forwards to the buyer through whatever channel
they use (the design-doc-spec'd "operator delivers the
URL" flow)
- The modal deliberately doesn't show a quote preview before
submit (the buyer-quote endpoint requires the buyer's signed
license key, which the admin doesn't have). Server-side
response carries the actual numbers when the operator commits.
Future polish: a separate admin-mode quote endpoint could
render the preview pre-submit.
Tests unchanged (77 still passing) — pure UI commit, no Rust
changes. The behavior the UI drives is fully covered by the
api.rs admin_change_tier_* tests added in c5d716a.
Closes the cancellation UX loop opened by 5d7f68f. Operators can now:
- See all subscriptions on a dedicated sidebar tab (with status filter
pills: All / Active / Past due / Cancelled / Lapsed)
- One-click cancel an active or past_due sub via the row's Cancel
button (a confirm dialog also captures an optional reason for the
audit log)
- See cadence (monthly / quarterly / annual / every Nd), listed
price (in original currency), next renewal, and consecutive
failures at a glance
Cancel button is hidden on already-cancelled and lapsed rows. Status
badges color-coded: green=active, amber=past_due, neutral=cancelled,
red=lapsed.
The reason prompt uses the browser's built-in `prompt()` for the
v0.2.x cut — small modal upgrade in a follow-up if operators ask
for richer affordances (buyer-vs-admin attribution dropdown, etc.).
Phase 4 surfaces the recurring-subscription schema (migration 0011) and
renewal-worker (Phase 2, commit 7007bf8) through every layer operators
and buyers actually see:
API
- Policy struct + repo gain is_recurring, renewal_period_days,
grace_period_days, trial_days. RecurringConfig / RecurringUpdate
helper structs keep create_policy / update_policy signatures
manageable.
- CreatePolicyReq + UpdatePolicyReq accept all four fields. Validation
rejects internally inconsistent combos (recurring=true with period=0,
trial > renewal period, period >5y, grace >90d).
- New tier::enforce_recurring_feature gate. Pro/Patron only — Creator
and Unlicensed get a 402 with upgrade_url. The gate fires on both
create-policy and the false→true transition in update-policy.
- list_public_policies now surfaces is_recurring, renewal_period_days,
trial_days so SDKs and the buy page can render cadence.
Admin UI (web/index.html)
- Create-policy form gets a "Recurring subscription (Pro)" section:
is_recurring checkbox + cadence preset (monthly/quarterly/etc/custom)
+ grace period + trial days. Live enable/disable: the inputs gray
out unless the box is ticked, and the custom-days input grays out
unless "Custom" is selected.
- Edit-policy modal mirrors the same section, pre-populated from the
policy's current values.
- Policies-list table shows a gold "every Nd" badge alongside the
trial badge so operators can see at a glance which policies renew.
Buy page (/buy/<slug>)
- Tier cards on a recurring policy render a "Renews monthly/annually/
every N days" meta line + a "/mo" / "/yr" / "/Nd" suffix on the
price unit, so the headline reads "$25 / mo" not just "$25".
- First-cycle trial banner shows when trial_days > 0.
- TIERS JSON map exposes is_recurring + renewal_period_days +
trial_days so the JS price-update path keeps the cadence suffix
in sync when the buyer clicks between tiers.
Tests (+4, total now 53)
- recurring_policy_blocked_on_creator_tier — 402 + upgrade_url
- pro_tier_creates_monthly_recurring_policy — full create + verify
via both admin GET and public list endpoint
- recurring_requires_positive_period — validator rejects period=0
- edit_policy_to_recurring_respects_tier_gate — Creator 402 on flip,
Pro 200 on same flip, name-only PATCH on already-recurring policy
doesn't re-fire the gate after downgrade
Drive-by: wrap the state-machine ASCII diagram in subscriptions.rs in
a ```text fence so cargo's doc-test runner stops trying to compile box
characters as Rust tokens.
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.