Pure UX bundle from the testing batch. None individually changes
behavior; together they remove a half-dozen sharp edges.
1. Policy-list duration column: human-readable
`31536000s` / `604800s` / `0s` are now `1 year` / `1 week` /
`perpetual`. New `fmtDuration()` helper handles common cadences
(1 day, 1 week, 1 month, 3 months, 6 months, 1 year, 2 years)
with arithmetic fallbacks for non-canonical values. Grace
column gets the same treatment with "none" for 0.
2. "Preview buy page" button per product header
The Policies tab's per-product card now has a "Preview buy
page" button on the right side of the header (when ≥ 1
public+active policy exists). Opens /buy/<slug> in a new
tab. tableCard() helper grew an optional headerAction param.
3. Buy page tier card: "Select" → "Selected"
When a tier becomes the active selection, its button label
flips to "Selected" while other tiers' buttons stay "Select".
Combined with the existing .selected card-border styling
gives buyers an unambiguous "yes, this tier is what's tied
to the price card below" cue.
4. Licenses page POLICY column shows display name
Was showing slug (`recurring`, `core`, `creator`); now shows
the operator-set display name (Recurring Pro, Core, Creator)
primary, with the slug as a smaller mono-font line below.
Operators see what the buyer sees while keeping the slug
visible for SDK reference. (Subscriptions tab already
handled this pattern; this brings Licenses in line.)
5. Change Tier dropdown: "(current)" annotation
Current tier now appears in the dropdown but with " · current"
appended and `disabled` attribute set. Operator sees what
they're starting from but can't pick the no-op. Auto-selects
the first SELECTABLE option so the modal opens with a valid
target ready. formSelect() helper grew per-option `disabled`
support.
6. Single "Switch active payment provider" StartOS action
The two old "Activate BTCPay" / "Activate Zaprite" actions
collapsed into one dropdown-driven action. Operators saw the
pair as confusing — both appeared alongside Connect /
Disconnect / Status, and operators couldn't tell at a glance
which one was currently active. New action pre-fills the
dropdown with the currently-active provider so opening it is
immediately informative.
Old action ids retained as visibility:'hidden' shims for
back-compat with any operator scripts pointing at them.
Test count unchanged; UI-only changes don't touch any test
fixtures.
Closes the gap from :2 where Connect Zaprite swapped the
in-memory provider but BTCPay would silently re-take active on
the next daemon restart (because the boot-time loader picked
BTCPay first whenever btcpay_config was present, regardless of
operator intent).
What changed:
**New settings key `active_payment_provider`** in the existing
settings table. Records the operator's last explicit choice
('btcpay' | 'zaprite' | NULL = no preference). Both
btcpay_config and zaprite_config can coexist; the flag is what
determines which one the daemon loads.
**Boot-time loader respects the preference.** main.rs now reads
the flag at startup. If set to 'zaprite', Zaprite wins; if set to
'btcpay', BTCPay wins; if unset (legacy installs), falls back to
the previous BTCPay-first ordering. Cross-load fallbacks log a
WARN and try the other provider — operators with a stale flag
pointing at a wiped config don't boot unconfigured.
**Connect endpoints write the preference.**
- finish_connect (BTCPay) now sets the flag to 'btcpay' on
successful authorize-callback completion.
- ZapriteAuthorize::connect now sets the flag to 'zaprite' on
successful API-key validation.
- Both Disconnect endpoints clear the flag IF it pointed at the
provider being disconnected — but leave it alone if it pointed
at the OTHER provider (different operator intent).
**New endpoints for fast switching without re-Connect:**
- GET /v1/admin/payment-provider/status — both configs' state +
current preference + runtime active provider, in one call.
- POST /v1/admin/payment-provider/activate { provider: "btcpay" |
"zaprite" } — flips the active provider and the flag together,
without going through the full Connect flow. 400 if the named
provider isn't configured (operator must run Connect first).
**New StartOS Actions** under existing groups:
- "Activate BTCPay" (in BTCPay group)
- "Activate Zaprite" (in Zaprite group)
Both call the new activate endpoint. Operators with both
providers configured can flip back and forth in one click.
**Test:** payment_provider_preference_round_trip pre-seeds both
configs, walks through Activate-Zaprite → Activate-BTCPay →
attempt-Activate-on-wiped-config → bad-provider-name → manual
write/read of the preference key. Pins the contract.
Test count: 42 (was 41; +1).
Migration not needed — settings table from 0005 already has the
key/value/updated_at shape we need.