9df190832883b1458d750cab8d551cd9c3cde9ac
52 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9df1908328 |
WIP — BTCPay connect rewrite + webhook URL refactor + thank-you fix (part 3b)
Closes out the remaining "all callers of the deprecated active-provider
shim" surface: BTCPay connect/disconnect/status now follows the same
merchant-profile-aware shape as Zaprite did in 3a, the webhook router
gets a path-keyed shape so deliveries go to the right provider's
secret, the thank-you page reads the invoice's recorded provider id
(not "the active one"), and the legacy `activate` endpoint is removed.
migrations/0022_btcpay_state_profile.sql (new)
Adds merchant_profile_id (nullable FK) to btcpay_authorize_state so
the BTCPay OAuth state token can round-trip the operator's profile
pick between start_connect and the callback. Without this, multi-
profile operators couldn't authorize a SECOND BTCPay store onto a
non-default profile.
btcpay/config.rs
record_authorize_state takes merchant_profile_id; consume_authorize_state
now returns Option<String> so the callback knows which profile to
attach the new provider row to.
api/btcpay_authorize.rs (full rewrite)
start_connect accepts an optional merchant_profile_id (defaulting to
the default profile), refuses if that profile already has a BTCPay
provider attached (unique-index-friendly 409 message), and records
the profile id on the CSRF state token. The OAuth round-trip carries
the profile id back via the state token, not via a query param —
state-token-by-row is more robust than depending on BTCPay preserving
redirect-URL query params during the consent dance.
finish_connect (the callback's inner path):
- Pre-generates the payment_providers row id so it can be baked into
the BTCPay-side webhook callback URL.
- The webhook URL we register with BTCPay is now path-keyed:
/v1/btcpay/webhook/{provider-id}. Each profile's BTCPay store gets
isolated deliveries.
- INSERTs into payment_providers (kind='btcpay', api_key, base_url,
webhook_id, webhook_secret, store_id, attached to the chosen
profile) instead of upserting the singleton btcpay_config row.
- Populates the back-compat state.payment singleton ONLY when this
is the first provider on the default profile (so the few remaining
legacy state.payment_provider() callers still work without a
daemon restart).
disconnect accepts an optional provider_id; defaults to "the BTCPay
provider on the default profile" for back-compat with the existing
admin UI's single Disconnect button. Best-effort BTCPay-side webhook
+ API key revocation unchanged. DELETE FROM payment_providers WHERE
id = ? instead of clearing btcpay_config.
status + payment_methods report on the default-profile BTCPay row for
the legacy admin UI. Multi-profile operators will use the new
/v1/admin/merchant-profiles endpoints (part 4).
api/webhook.rs
Split into two entry points:
- handle_for_provider — the new path-keyed shape
(`/v1/{kind}/webhook/:provider_id`). Looks up the named provider
via state.payment_provider_by_id, validates the payload against
THAT specific provider's secret, then runs the inner pipeline.
- handle — back-compat for the bare /v1/{kind}/webhook path. Routes
to whichever provider is on the default profile. Kept so any
in-flight pre-:52 webhook delivery or admin misconfiguration
doesn't silently drop on the floor.
Both share an extracted handle_inner that does the actual settle /
expire / refund processing.
api/mod.rs
Route registrations:
- Adds /v1/{btcpay,zaprite}/webhook/:provider_id POST handlers.
- Removes the legacy /v1/admin/payment-provider/activate route
(the shim function is gone).
Thank-you page provider-kind lookup ports from the deprecated
read_active_provider_preference to: invoice.payment_provider_id ->
payment_providers.kind -> ProviderKind. Falls back to the default
profile's first provider if the invoice predates migration 0021.
api/payment_provider.rs
Reduced to just the back-compat status endpoint. The activate
endpoint is removed entirely — there's no "active" preference to
flip in the merchant-profile model. Status returns the same
btcpay_configured / zaprite_configured / active shape the existing
admin UI consumes, plus a new providers[] array for callers that
want the full picture.
Build: cargo check passes. Only two warnings remaining — both
expected:
- recover.rs unused-import (pre-existing, unrelated)
- SETTING_ACTIVE_PROVIDER inside the shim itself (the legacy fallback
branch in read_active_provider_preference that runs during the
pre-:52 upgrade window before migration 0020 has dropped the
settings row)
What's left for :52:
- New admin endpoints for merchant-profile + rail-preference CRUD
- Admin UI in web/index.html (biggest remaining chunk — Merchant
Profiles section + product picker + buy-page brand block +
rail picker)
- Tier-cap wire-up for unlimited_merchant_profiles
- Version bump + release notes + sandbox test
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
cf251fc63f |
WIP — rewrite Zaprite connect/disconnect/status for merchant profiles (part 3a)
Replaces the singleton-config-row implementation in api/zaprite_authorize.rs
with the new payment_providers + merchant_profiles model. The connect
flow now takes an optional `merchant_profile_id` (default = the auto-
created default profile) and INSERTs a row in `payment_providers`
instead of upserting the singleton `zaprite_config` table. Operators on
Pro/Patron can pass a non-default profile id to set up per-business
Zaprite orgs side-by-side.
Webhook URLs returned to the operator now include the provider id —
`/v1/zaprite/webhook/{provider-id}` instead of the legacy bare
`/v1/zaprite/webhook` — so each profile's Zaprite org gets its own
isolated webhook receiver. The webhook router refactor that consumes
this URL shape lands in a follow-up commit (today the legacy route
still works because the path-param refactor hasn't happened yet — this
commit just changes what URL the connect endpoint reports back).
Disconnect now takes an optional `provider_id` body field. When NULL,
falls back to "the Zaprite provider on the default profile" for
back-compat with the existing single-profile admin UI's disconnect
button. Multi-profile operators name the specific provider via the
new merchant-profile-scoped admin endpoints (landing in part 4).
Status endpoint similarly reports on the default profile's Zaprite
attachment for the existing admin UI's payment-providers card.
Removed the `write_active_provider_preference` call (deprecated no-op
in the new model — providers aren't "active," they attach to profiles
and are looked up per-product). Removed the `state.set_payment_provider`
call EXCEPT when this is the very first provider on the default
profile — in that case we populate the back-compat singleton so the
small number of remaining state.payment_provider() callers (currently
just the thank-you page) keep working without a daemon restart.
Build: cargo check passes. Eight remaining deprecation warnings in
api/btcpay_authorize.rs (same rewrite due in part 3b),
api/payment_provider.rs (the legacy activate endpoint — to be
replaced), and api/mod.rs (thank-you page provider lookup).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7c4dfbacd2 |
WIP — port purchase/subscriptions/reconcile/upgrade/tipping to merchant-profile resolution (part 2)
Threads the merchant-profile + payment-provider snapshot semantics through
every call site that used to call state.payment_provider() (the legacy
"active provider" singleton). New invoices now record which provider
settled them; subscriptions snapshot both merchant_profile_id and
payment_provider_id at creation so mid-cycle product re-routing doesn't
redirect existing buyers; the reconciler picks the right provider per
invoice; tipping draws from the same Bitcoin balance that received the
purchase; tier-change invoices stick with the buyer's existing merchant
identity.
migrations/0021_invoice_provider_link.sql (new)
Adds invoices.payment_provider_id (nullable FK), backfills existing
pending/settled rows to the earliest-connected provider on the default
profile. Additive — no drops, no removals. Companion to 0020 from the
foundation commit.
models.rs
Invoice gains payment_provider_id: Option<String>.
db/repo.rs
row_to_invoice reads the new column. All three invoice SELECTs include
it. create_invoice + create_invoice_with_currency take a new optional
payment_provider_id parameter and persist it on INSERT.
subscriptions.rs
Subscription struct gains merchant_profile_id + payment_provider_id
(snapshotted on create). SUB_COLS + row_to_subscription + the manual
SELECT in find_lapsing_subscriptions all updated. create_subscription
accepts both new fields and writes them on the INSERT row.
renew_one — reads the sub's payment_provider_id snapshot and resolves
the provider via state.payment_provider_by_id(). Falls back to the
legacy state.payment_provider() for any subs created pre-:52 that
the migration backfill missed.
capture_zaprite_payment_profile — uses the INVOICE's provider, not
"the active one." Saved-profile ids are scoped per Zaprite org; using
the wrong provider would fail the lookup.
try_auto_charge_zaprite — uses the sub's snapshotted provider (same
rationale).
reconcile.rs
Per-invoice provider lookup. Each pending invoice is reconciled
against state.payment_provider_by_id(inv.payment_provider_id), with
graceful fallback for NULL provider ids. No more single-global-
provider assumption.
tipping.rs
Tip pay-out uses the provider that settled the license's purchase
invoice (joined via licenses.invoice_id). Same rationale as the
capture hook — the tip needs to draw from the right LN node.
api/upgrade.rs (both buyer-driven and admin-driven tier-change sites)
Tier-change invoices ride on existing licenses. The right provider
is whichever the license's subscription is snapshotted to (so the
proration charge settles to the same merchant identity that collects
renewal fees). Falls back to the invoice's recorded provider, then
the legacy default, for licenses with no subscription or pre-
snapshot rows.
api/purchase.rs
StartPurchaseReq gains an optional `rail` field
("lightning"/"onchain"/"card") for the future buy-page multi-rail
picker. When omitted (today's behavior), the daemon picks the first
rail the product's merchant profile exposes — which is correct for
single-provider operators AND back-compat for any pre-:52 client
not yet sending the field.
Provider resolution: product → merchant_profile → rail →
resolve_provider_for_profile_rail. The redirect_url defaults to the
profile's post_purchase_redirect_url (with {invoice_id} substitution)
if set, else Keysat's own /thank-you. New invoices carry their
provider's id via the new create_invoice_with_currency parameter.
api/webhook.rs
issue_license_for_invoice now passes snapshot fields when calling
subscriptions::create_subscription — both merchant_profile_id (from
product lookup) and payment_provider_id (from the invoice row).
main.rs
Replaces the legacy "active provider preference" boot loader with a
default-profile-first-provider warm-up. The legacy state.payment
singleton stays populated for back-compat with call sites that
haven't yet migrated to the on-demand resolution path. Pre-migration
fallback to the old singleton-config loaders preserved so the
daemon still boots cleanly on a DB that hasn't run 0020 yet.
Remaining for part 3:
- BTCPay + Zaprite connect flows take merchant_profile_id and
INSERT into payment_providers (currently still write to the
dropped singleton tables, broken post-migration).
- api/payment_provider.rs activate endpoint becomes irrelevant in
the new model — repurpose as list-providers, or delete.
- Thank-you page (api/mod.rs) provider-kind lookup ports to the
invoice's recorded provider.
- Webhook routes refactor to /v1/{kind}/webhook/{provider_id}.
- Admin UI for Merchant Profiles + product picker + buy-page brand
block + rail picker.
- Tier-cap wire-up for unlimited_merchant_profiles entitlement.
- Version bump to :52 + release notes.
Build: cargo check passes. Deprecation warnings remaining flag exactly
the call sites listed above as the part 3 todo list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
04e0dcd591 |
WIP — merchant profile foundation (multi-provider payment model, part 1)
Lays the schema + types + resolution layer for the merchant-profile-aware
multi-provider model documented in plans/multi-provider-payment-model.md.
Does NOT yet migrate any existing call site — legacy `state.payment_provider()`
and the singleton config tables continue to work via deprecation shims so
the daemon keeps running unchanged on this checkpoint.
This commit is intentionally a WIP foundation, not a shippable release —
no version bump, no release notes, no admin UI, no call-site migration.
A follow-up cycle ports purchase / subscriptions / reconcile / upgrade /
tipping to the new resolution layer, rebuilds the BTCPay + Zaprite connect
flows around merchant_profile_id, refactors webhook URLs to
/v1/{kind}/webhook/{provider_id}, ships the Merchant Profiles admin UI
section, wires the tier-cap, and bumps to :52 with the one-way migration
release notes.
What landed:
migrations/0020_merchant_profiles.sql
Full schema + data port + DROP of the singleton tables. Creates
merchant_profiles, payment_providers (FK to profile, unique per
(profile, kind)), merchant_profile_rail_preferences (tie-breaker
when a profile has 2 providers serving the same rail). Adds
merchant_profile_id to products + (merchant_profile_id, payment_provider_id)
to subscriptions for the snapshot-on-create semantics. Ports
btcpay_config + zaprite_config + active_payment_provider setting
into the new tables, then drops them. Master operator post-migration
step: update the Zaprite webhook URL on the Zaprite dashboard to
the new /v1/zaprite/webhook/{provider-id} form (or click Reconnect
Zaprite in the new UI once it ships).
src/merchant_profiles.rs (new module)
MerchantProfile struct + NewMerchantProfile + MerchantProfileUpdate
input types. Business-logic CRUD helpers: create, get, get_default,
require_default, list, update, set_default, delete, for_product.
Delete refuses if products or active subs are attached or if it's
the default profile. Tier-cap check stubbed with a TODO for the
next chunk's tier.rs wire-up.
src/db/repo.rs (+469 lines)
Repo helpers: create/get_by_id/get_default/get_for_product/list/
update/set_default/delete for merchant_profiles + count helpers
for products/active_subscriptions per profile. PaymentProviderRow
struct + create/get/list_for_profile/list_all/delete. RailPreference
struct + list/set/clear helpers. update_merchant_profile builds a
dynamic SET clause so partial updates don't clobber fields the
caller didn't touch.
src/payment/mod.rs
Rail enum (Lightning / Onchain / Card) + ProviderKind::parse +
rails_for_kind static mapping. build_provider(row, public_base) ->
Arc<dyn PaymentProvider> factory that dispatches on kind to construct
a typed BtcpayProvider or ZapriteProvider from a payment_providers
row. PaymentProvider trait gains a default served_rails() impl
returning rails_for_kind(self.kind()).
Deprecation shims: SETTING_ACTIVE_PROVIDER constant +
read_active_provider_preference + write_active_provider_preference
stay callable so btcpay_authorize/zaprite_authorize/main.rs/the
thank-you page still build. read_active_provider_preference now
reads from the new payment_providers table (returns the kind of
the first provider attached to the default profile), falling back
to the legacy settings-table read pre-migration. write_* is a no-op.
Each shim has a #[deprecated] attribute so the build surfaces
exactly which call sites still need porting (lit up in the
follow-up cycle's TODO).
src/api/mod.rs (AppState)
New methods alongside the existing payment_provider() shim:
- payment_provider_by_id(id) — looks up a row, builds the provider
- merchant_profile_for_product(product_id) — resolves via products.merchant_profile_id, falls back to default
- resolve_provider_for_profile_rail(profile_id, rail) —
preference table -> single candidate -> deterministic earliest-
connected with WARN. Returns (row, Arc<dyn PaymentProvider>).
- resolve_provider_for_product_rail(product_id, rail) — convenience
wrapping the previous two.
src/lib.rs
Registers the new merchant_profiles module.
Build state: cargo check passes. Only warnings are the pre-existing
unused-import in recover.rs and the deprecation lint firing on the
five legacy call sites enumerated in the WIP plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
4cde540b60 |
v0.2.0:51 — Zaprite recurring polish from sandbox testing (:46-:51)
Six routine bumps land together, all driven by end-to-end sandbox testing
of the Zaprite recurring auto-charge path that shipped in :45:
:46 Provider create-invoice failures now surface the underlying cause.
Switched user-facing format from `{e}` to `{e:#}` so the full anyhow
chain reaches the buy page; added `tracing::error!` for symmetric
daemon-log visibility. Without this, failed checkouts showed only
"ZapriteProvider.create_invoice" with no clue what actually went wrong.
:47 Zaprite recurring purchases now create the contact upfront. Sandbox
surfaced that `allowSavePaymentProfile: true` requires an explicit
`contactId` on the order — passing only `customerData: { email }`
returns 400. Added `client.create_contact(email, name)` + threaded
the returned id as `contactId`. Graceful degradation: recurring +
no buyer_email → one-shot mode with a warn log; renewals fall back
to manual-pay.
:48 Thank-you page copy is now provider-aware. The wait-page lede
hardcoded "Your Bitcoin payment was received" + Lightning/on-chain
timing — wrong for Zaprite card payments. Reads SETTING_ACTIVE_PROVIDER
and branches the copy + the JS polling-status text accordingly.
:49 Zaprite saved-profile capture: full diagnostic logging + reconciler
path. Discovered five recurring subscriptions settled successfully
but with NULL `zaprite_payment_profile_id`. Root cause: capture
hook had six silent early-return paths, AND the reconciler (which
catches missed webhooks) never called `on_invoice_settled` so subs
created via that path never got their profile captured. Added warn
logs on every early-return + wired capture into `reconcile.rs`'s
post-license-issuance flow.
:50 Webhook event-type extraction probes multiple field names. Confirmed
deliveries were arriving but all logged as "non-actionable event_type=
" — Zaprite doesn't use the convention-suggested `event` field. Now
probes `event` / `eventType` / `type` / `name`, first non-empty wins.
Also widened the order-id probe to include `data.object.id`. On a
miss, warn-logs the raw payload truncated to 2KB so the actual field
name can be added to the probe list.
:51 Zaprite `order.change` event is now actionable. The :50 probe-fix
surfaced that Zaprite's primary delivery shape is a generic
`order.change` event that just says "something about this order
changed" — the receiver has to look at `/data/status` to figure out
what actually changed. They do NOT send the convention-suggested
`order.paid` / `order.complete` events. Added an `order.change`
match arm that branches on status (PAID/COMPLETE/OVERPAID →
InvoiceSettled, EXPIRED → InvoiceExpired, INVALID/CANCELLED →
InvoiceInvalid, in-flight states → Other). End result: webhook-
driven settles now flip subscriptions within seconds of Zaprite's
callback instead of waiting ~45s for the reconciler.
Net effect of the batch: the recurring auto-charge flow is now validated
end-to-end against the Zaprite sandbox. Buyers paying with a card via
Stripe-backed Zaprite trigger contact + saved-profile creation, the
webhook fires `order.change` with status PAID, Keysat captures the
saved-profile id within seconds, and the renewal worker is wired to
auto-charge subsequent cycles. Manual-pay fallback intact for buyers
who decline save-card or pay via Bitcoin/Lightning.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
fea6995192 |
v0.2.0:45 — Zaprite recurring auto-charge + mobile-friendly admin UI
Two routine bumps land together in this release: :44 — Admin UI mobile pass. Adds a phone breakpoint (≤640px) and hamburger-driven off-canvas drawer (≤720px) to the embedded web/index.html so triage flows (status check, license lookup, revoke) work from a phone. Tables now scroll horizontally inside their card, tap targets bump to ~40px, stats grid collapses to 1-up, toolbar inputs go full-width. Desktop layout unchanged. CSS + small JS toggle. :45 — Zaprite recurring auto-charge wired end-to-end. Closes the gap the subscriptions.rs module comment promised but never delivered: first-cycle invoices on recurring policies set allow_save_payment_profile, the on-settle hook captures the resulting Zaprite paymentProfileId into four new nullable columns on the subscriptions table (migration 0019, additive only), and the renewal worker calls POST /v1/orders/charge against the saved profile instead of waiting for manual pay. On charge failure (declined card, expired profile, network) the worker logs + audits + falls through to the existing subscription.renewal_pending event so the buyer still has a recovery path. Two new operator webhook events: subscription.auto_charge_initiated and subscription.auto_charge_failed. BTCPay subs and Zaprite subs whose buyer paid with Bitcoin/Lightning or declined the save-card prompt are untouched. NOT yet end-to-end tested against the Zaprite sandbox — control flow follows api.zaprite.com/llms.txt but exact failure-body shapes for declined cards aren't documented; sandbox validation pass recommended before relying in production. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c71345f002 |
v0.2.0:43 — BTCPay success page: return to Keysat, not StartOS
Connect now lives inside Keysat's admin UI, so the post-authorize return target is Keysat's own tab. One-line copy in two paths. |
||
|
|
17d5df72d3 |
v0.2.0:42 — revert implicit Patron→Pro expansion from :41
The only affected license was the operator's own pre-launch self-license under an earlier entitlement scheme. New Patron licenses issued from the corrected master-Keysat policy carry the right entitlements in their signed payload. The implicit expansion was paying ongoing complexity (magic-slug behavior, hardcoded list divergence on rename) for a one-shot migration case. Affected operators: re-issue + Activate Keysat license. The new key overwrites /data/keysat-license.txt and self_tier picks up live without a restart. |
||
|
|
a3662de6d8 |
v0.2.0:41 — Patron implies Pro; BTCPay Connect back to one-click authorize
Patron entitlement now expands to the full Pro surface (unlimited_products / _policies / _codes, recurring_billing, zaprite_payments) in tier::current(). Existing Patron customers get the implied entitlements without re-issuing. BTCPay Connect: replace the four-field paste form (Base URL + API key + Store id + Webhook secret) with the original one-click button that fetches an authorize URL from /v1/admin/btcpay/connect, opens it in a new tab, and polls /v1/admin/btcpay/status until the BTCPay callback finishes. Zaprite path unchanged. |
||
|
|
1a14b9c2e3 |
v0.2.0:39 — Buy page: render tier card for single-public-policy products
Previously the tier picker gated on `policies.len() < 2` and returned an empty string when a product had only one public policy. Buyers saw just the price card + form — none of the entitlements, marketing bullets, or description the operator had carefully authored on that tier. Reported against the Recap product, which has 3 policies but only Pro public; Pro's bullets were invisible to buyers. Fixed: - render_tier_picker gate flipped from `< 2` to `is_empty()`. A single public policy now renders a single tier card. - New `.tiers-1` grid class: one centered column at ~480px max-width. Keeps the single card from stretching to the full 1040px container. - `n` computation extends to handle 1 in the existing match arm. The price card below the picker still renders unchanged for the single-policy case — acts as the buy-confirmation summary. Operators keeping most tiers private and only exposing one to buyers now get the same rich tier-card render that multi-tier products always had. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
aaf8bddfe4 |
v0.2.0:37 — "Limited" → "Limited discount" on launch-special meta
Adds the word "discount" so buyers don't misread the limit as a license count. "Limited: 10 remaining" was ambiguous; "Limited discount: 10 remaining" is unmistakable. Landing-page dynamic tier-card JS matches in a separate commit on the keysat-xyz-landing repo. Cosmetic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
e05d357a5a |
v0.2.0:36 — Launch-special remaining: "N remaining", drop the total
Buy-page tier card's "Limited: N of M remaining" line now reads just "Limited: N remaining". The total cap (M) is operator-private — there's no upside to exposing initial launch volume to buyers, and it can make a tier feel smaller than the operator intends. Symmetric landing-page change (index.html dynamic tier-card JS) ships alongside in a separate commit on the keysat-xyz-landing repo. Cosmetic; no API or schema change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a0995c9c31 |
v0.2.0:35 — Free tiers render as "Free" on the buy-page tier card
Previously the server rendered a 0-priced tier as "0 sats" (or
"0.00 USD") in the tier-card headline. The price card below the
tier picker already swapped to "FREE" via the JS path, so the
two surfaces disagreed.
Now: when post-discount price is 0, the tier card renders the
headline as "Free" with no unit suffix and no cadence-suffix
("Free /yr" would be incoherent). recurring_meta ("Renews
annually") still surfaces beneath for recurring-free edge cases,
so cadence isn't lost — just not stuffed into the headline.
Cosmetic; no API or schema change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
6c8df98cfd |
v0.2.0:34 — Buy page: pre-populate featured code in discount input
Previously a tier's featured (launch-special) discount auto-applied silently at payment time but the discount-code input was empty, leaving buyers unsure whether they needed to type anything to claim the slashed price. Now: when a tier has an active featured discount, selectTier() pre-fills codeInput with the code string and flips into the "applied" state — appliedCode set, status badge shows "Launch special applied". The price card has always rendered the struck-original + discounted-current price; this change just makes the form match what's already visually claimed. New `autoAppliedFeatured` flag distinguishes auto-populated codes from buyer-typed ones: - On tier switch, the reset block also clears the input when autoAppliedFeatured was true (the prior featured code doesn't necessarily apply to the new tier; better to start fresh). - Buyer-typed codes are NOT cleared on tier switch — they may be valid for the new tier, and the buyer can hit Apply to check. - Any keystroke in codeInput, or a successful manual Apply, flips the flag to false. JS / template only; no API or schema change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
752beff429 |
v0.2.0:33 — Drop unused invoice_id_safe warning
`let invoice_id_safe = html_escape(&invoice_id);` in api::thank_you was computed but never referenced — the template uses invoice_id_json for the inline JS, and the visible invoice id renders from that JSON via JS. One-line removal; cargo check now warning-free. No behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
1bd1bde895 |
v0.2.0:29 — Tier-card cross-card horizontal alignment via subgrid
Visually equivalent sections of each tier card (names, prices, first feature bullet, Select button) now line up horizontally across all visible tier cards. Cards with fewer / shorter sections get extra whitespace in the rows they don't fill — the explicit tradeoff the operator asked for, in service of a cleaner grid. - .tiers parent grid now declares 8 explicit row tracks. Each .tier is a subgrid that shares those rows. - Each section class (.tier-launch-meta, .tier-name, .tier-price- original, .tier-price, .tier-meta-block, .tier-description, .tier-features, .tier-select-btn) gets an explicit grid-row. Missing sections leave the row empty without breaking alignment. - Meta lines (duration, recurring, trial banner, trial flag) now wrapped in a single .tier-meta-block so they land in one row as a flex-column. - Launch-meta separated from featured_ribbon so each can occupy its own grid row independently (vs. the ribbon string previously embedding the meta div in-flow). - Side fix: .tier.has-launch swapped from overflow:hidden to clip-path polygon that preserves 20px above the card. The popular pill returns to top:-10px (above the card) without being clipped. Removed the v0.2.0:26-27 padding-top:36px workaround that pushed the pill inside. CSS + HTML composition only; public API JSON unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4377dfbb34 |
v0.2.0:27 — Single tier-features ul; popular pill spacing fix
Two remaining buy-page issues from :26: - Tier-card feature list. Stop fighting the two-<ul> boundary with margin tweaks. Build ONE <ul class="tier-features"> server-side containing marketing bullets and entitlements in the operator- controlled order. Both groups render with identical ✓ + li styling, visually indistinguishable to the buyer. No list boundary = no gap. - "MOST POPULAR" + "Limited: ..." collision. The :26 fix moved the popular pill to top:8px (inside the card) for has-launch tiers, but that landed it on top of the launch-meta line. Push the card content down via padding-top:36px on .tier.has-launch.highlighted (35px when also .selected to compensate for the thicker border). CSS + HTML composition only; no schema, API, or SDK change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9628001f69 |
v0.2.0:26 — Buy-page + entitlement-picker visual polish
Cluster of small visual fixes: - Tier-card feature list seam. Zeroed margin-top between adjacent marketing-bullet + entitlement lists in either order so the gap between lists matches the within-list gap. Reads as one column. - MOST POPULAR clip. When a tier was both highlighted AND had a launch ribbon, overflow:hidden (for the ribbon overhang) was also clipping the popular pill that floats above the card. Pill drops to top:8px (inside the card) only for the highlighted + has-launch combination. - Price card width. :23 stretched the price card to 1040px alongside the headline; that overpowered everything below the tier picker. Constrained back to 560px (centered); headline stays full-width. - Entitlement bubble picker theme. Selected chips switch from gold- filled to navy-filled with cream text (matches "Selected" tier- select-btn + Featured-ON toggle). Hidden-on-buy state drops the strikethrough — opacity:0.5 on the whole pill is the signal. - Discount-code policy multi-pickers follow the same navy theme on Create + Edit (re-aligned from the brief gold pass in :25). - Admin Policies grid also drops strikethrough on hidden chips; opacity-only, italic "(hidden on buy)" hint stays. CSS + inline-style only; no data, schema, or API change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
033a1f4a6a |
v0.2.0:24 — Per-entitlement "hide on buy page" toggle
Decouples "what the license grants" from "what the buyer sees on the tier card." Operator can mark individual entitlements as hidden from the buy page tier-card display; the issued license still carries them. Enables the "Everything in Creator, plus:" marketing pattern without duplicating implied entitlements on higher-tier cards. - entitlementBubblePicker accepts a third `initialHidden` param and exposes a `readHidden()` method alongside `read()`. Each granted chip gets a small eye toggle (👁 visible / 👁🗨 hidden). Click chip name = grant/revoke. Click eye = hide-on-buy toggle. De-selecting a chip clears its hidden state automatically. - New per-policy metadata: hidden_entitlements: string[]. Buy page filters before rendering tier-card entitlement chips. Public /v1/products/<slug>/policies exposes the array so SDKs and dynamic pricing pages stay in sync. - Admin Policies grid still shows ALL entitlements (operator-truth view) but hidden ones get muted opacity + strikethrough + a small "(hidden on buy)" italic hint. No schema change; pure metadata pass-through. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0e46ce399d |
v0.2.0:23 — Buy-page polish: width balance, auto-discount, bullet gap
Three concrete fixes after :21 rolled the wider buy page:
- Layout proportions. Headline + price card span the full 1040px
container with center-aligned text (matches the tier picker
width). Only the email/discount/pay form stays narrow at 560px
since input fields look stretched at 1040px.
- Featured discount auto-applies on the headline price. Tier JSON
now carries each tier's featured-discount snapshot, and the JS
selectTier() renders strike-through + discounted price when an
active featured code applies. Tier switching also re-applies the
featured code for the new tier instead of resetting to base.
- Marketing-bullets gap. Added mirror CSS rule
`.tier-entitlements + .tier-bullets { margin-top:2px }` so the
bullets-below layout has the same tight visual continuity that
bullets-above already had.
Public buy-page CSS + JS only.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3c054c65db |
v0.2.0:22 — Policy scope is editable on discount codes
Lifts the "scope cannot be edited" rule for policies. Product scope remains read-only (moving a code between products has weird semantics for historical redemptions), but the tiers a code applies to can now be refined in-place via the Edit form's pill multi-picker. - repo::update_discount_code: new applies_to_policy_id param (Option<Option<String>>) alongside the existing applies_to_policy_ids multi field. Both update the right columns; caller passes a consistent pair so singular + JSON columns don't drift. - Admin PATCH endpoint: new optional `policy_slugs` field. Server resolves slugs against the code's existing product, then normalizes: - [] → both columns NULL (any policy on the product) - [one] → singular column set, JSON column cleared - [two+] → JSON column set, singular column cleared Sending no `policy_slugs` leaves scope alone (back-compat). - Edit form: pill multi-picker replaces the read-only Applies-to label. Pre-selected from the code's current allowed-policy set. Product label stays read-only above the picker. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
6fd7dd9302 |
v0.2.0:21 — Wider buy page (1040px) so 3-tier grids breathe
The public /buy/<slug> page was capped at 560px. With three tier cards side-by-side that made everything narrow and tall in a desktop browser. Bumped the outer container to 1040px so the tier picker matches the admin Policies page layout. - .wrap max-width: 560px → 1040px. - .wrap > :not(.tiers) max-width:560px + margin-auto so the form, price card, and intro text stay centered at reading width below the wider tier picker. - .topbar .inner widened 680px → 1040px to align with .wrap. - .eyebrow display:inline-flex → flex + width:fit-content so the margin:auto centering rule applies. Mobile breakpoints unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
094cf75e52 |
v0.2.0:20 — Multi-policy scope for discount codes
A discount code can now apply to a subset of policies on a product (e.g. "Patron and Pro but not Creator") instead of being limited to exactly one policy or the entire product. - Migration 0018 adds `applies_to_policy_ids_json` (nullable JSON array of policy ids). Legacy `applies_to_policy_id` stays as the singular fallback when the JSON column is empty/NULL. - `DiscountCode::allowed_policy_ids()` helper unifies multi + singular into one Vec. Purchase + preview scope checks consult it. - `find_applicable_featured_discount` now narrows multi-policy candidates in Rust (small candidate set; index-friendly SQL would require json_each, deferred). - Admin API: `POST /v1/admin/discount-codes` accepts `policy_slugs` (array) alongside the existing `policy_slug` (singular). Multi wins when both are present. PATCH does not allow scope edits — same rule as the singular field (disable + recreate to re-scope). - UI: pill multi-select replaces the policy dropdown on the create form. Edit modal's scope label renders the comma-separated list. UI + schema both back-compat: existing codes keep working unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
eb360a325e |
v0.2.0:19 — Marketing bullets: choose above or below entitlements
Operator picks where the free-form ✓ checkmark copy renders on each
tier card. Default "above" matches prior behavior; "below" is opt-in
per policy.
- New metadata field metadata.marketing_bullets_position ("above" |
"below"). Persisted only when bullets exist AND choice != default.
- UI: select next to the bullets textarea on create + edit forms.
- Admin grid: swaps marketingList + entChips order accordingly,
including the top-margin tighten-up so the lists hug each other.
- Buy page (buy_page.rs): swaps marketing_html + entitlements_html in
the tier-card template via destructured (first, second) tuple.
- Public /v1/products/<slug>/policies: exposes the position field as
"above" | "below" (normalized) so SDK consumers stay in sync.
UI-only/metadata-only; no schema, no SDK breaking change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
4334a9f044 |
v0.2.0:16 — Launch-special discount codes + marketing bullets
Major feature release.
Featured (launch-special) discount codes:
- New 'featured' flag on discount_codes (migration 0017). When true,
the buy page renders a diagonal LAUNCH SPECIAL ribbon + slashed
original price + new price for every applicable tier. Purchase
endpoint auto-applies the discount for buyers who don't type a
code. Operator-typed codes still win.
- find_applicable_featured_discount repo helper: most-specific match
(policy > product > global), tiebreak by created_at.
- GET /v1/products/<slug>/policies now returns featured_discount per
policy with the post-discount price computed server-side. SDK
consumers + the dynamic pricing page get this for free.
Marketing bullets on policies:
- metadata.marketing_bullets — operator-controlled copy that renders
as additional checkmarks above the entitlement bullets on both the
admin grid tier card and the buy page tier. For things like 'Up
to 5 products' or 'BTCPay integration' that aren't real
entitlement gates.
- Authored via textarea on draft + edit policy forms.
UI:
- 'Most popular' checkbox now on the draft tier card (was edit-only).
- Discount codes tab grouped by product (matching Licenses /
Subscriptions tabs). Each code row gets a 'featured' badge when
flagged.
All 87 tests still pass. Migration is additive, no SDK changes,
backwards-compatible.
|
||
|
|
76fe7fe6b9 |
v0.2.0:13 — CORS on public endpoints
Adds tower-http CorsLayer at the outermost router position so: - Browsers can fetch /v1/products/<slug>/policies, /v1/openapi.json, /v1/issuer/public-key, /v1/validate from any origin. Unblocks the dynamic pricing page on docs.keysat.xyz reading live tier config from licensing.keysat.xyz. - Preflight OPTIONS is handled by the CorsLayer directly, never reaches the session-bridge or any handler — so admin endpoints don't 401 on preflight. Security posture unchanged. Access-Control-Allow-Credentials is OFF. The combination of ACAO=* and no-credentials means a cross-origin page can read public responses but can't ride a logged-in admin session cookie to hit /v1/admin/*. Admin endpoints still require an explicit Bearer token, which browsers don't auto-attach cross-origin. Tests: +2 CORS regression tests (cors_allows_cross_origin_on_public_ endpoints, cors_preflight_returns_2xx_without_auth). Full suite: 85 passing. |
||
|
|
257669092b |
v0.2.0:11 + v0.2.0:12 — Archive, Settings, agent surface, machines redesign
Two release cycles prepared together: v0.2.0:11 (policy archive + safe- delete cleanup + brand-consistent confirm modals) and v0.2.0:12 (Settings tab + agent-friendly operator API + machines tab redesign + buyer-facing copy alignment). Highlights: - Migration 0015: policies.archived_at column. Archive button on tier cards; safe-delete relaxed to ignore revoked-license tombstones; renewal worker refuses archived policies. - Migration 0016: scoped_api_keys table. Four roles (read-only, license-issuer, support, full-admin) with bounded scopes. Master admin_api_key still works on every endpoint; scoped keys gated on endpoints wired through require_scope(). - New /v1/openapi.json — public, no auth. Curated OpenAPI 3.1 spec for agent / SDK discovery. - New Settings tab: Operator name + Payment providers panel + API keys management. Replaces 8 StartOS Actions (Zaprite all, BTCPay all, operator name, switch-provider). StartOS Actions pruned to 4 install-time essentials. - Machines tab rewritten: global default view grouped by product, filter pills with counts, quick-stats row, drill-down via new "Machines" button on each Licenses-tab row. New repo helper list_machines_admin joins machines x licenses x products server-side. - Branded confirmModal replaces every native window.confirm() call in the admin UI (7 callsites). - Enforce mode killed: KEYSAT_LICENSE_ENFORCE compile-time flag retired; daemon always boots; missing self-license -> Creator (free) tier. "Unlicensed" label gone from admin UI. - Zaprite gated on the new zaprite_payments entitlement (renamed from card_payments to reflect the broader gateway). - Creator code cap 5 -> 10. - KEYSAT_AGENT_GUIDE.md: auth, role-to-scope mapping, error envelope, webhook events, worked recipes. - Buyer-facing copy aligned with new positioning: "Bitcoin-native self-hosted software licensing" everywhere on production surfaces. - Cross-product safety section (Section 9a) added to KEYSAT_INTEGRATION.md. - 5 new API integration smoke tests covering OpenAPI, scoped API keys CRUD, role-elevation guard, and Zaprite-tier gating. Test count: 83 passing (was 78). All migration tests pass against 0015 and 0016 applied to populated DBs. |
||
|
|
68dfe7f6fc |
Product entitlements catalog (Phase 1: schema + admin + buy page)
Closes the request to make entitlements first-class on products
instead of free-text strings on policies. Operators declare the
closed list of entitlements a product offers — slug + display name
+ optional description — and policies pick from that list with a
click-to-toggle bubble UI. Buy page renders human-readable names
("AI summaries") with descriptions as tooltips, never the raw slug
("ai_summaries").
Schema (migration 0014):
- products.entitlements_catalog_json: nullable JSON column shaped
as [{slug, name, description}, ...]
- Auto-backfill on upgrade: for each existing product, derive a
catalog from the union of its policies' entitlement slugs, with
name = slug.replace('_', ' ') and empty description. Operators
can refine afterward.
- Products with no policy entitlements stay NULL (legacy
free-text mode preserved).
Server:
- Product struct gains entitlements_catalog: Option<Vec<EntitlementDef>>
- repo::set_product_entitlements_catalog (validates lowercase ASCII
slugs, uniqueness, defaults name to slug if empty)
- Product create/update API accept entitlements_catalog;
update uses double-Option PATCH shape so operators can clear
- Closed-list validation: when product has a non-empty catalog,
policy create + update reject any entitlement slug not in the
catalog with a clear error pointing at the right path
- /v1/products/<slug>/policies surfaces entitlements_catalog
in the product object so SDK consumers can render display
names client-side
- Buy page renders entitlement display names + description tooltips
on tier cards (falls back to raw slug for legacy entries that
predate the catalog)
Admin UI:
- New catalogEditor() helper (repeating slug/name/description rows
with add/remove buttons) embedded in product create + edit forms
- New entitlementBubblePicker() helper (click-to-toggle pill chips
showing display name with description tooltip)
- Policy create form: entitlements input swaps based on the chosen
product's catalog — bubble picker when catalog has entries,
legacy textarea otherwise. Rebuilds when operator changes
product.
- Policy edit modal: same bubble-picker-or-textarea swap, scoped
to the policy's product
- Policy list table: entitlement column shows display names
(resolved against the product's catalog) instead of slugs
Migration regression test verifies:
- Backfill correctly unions entitlements across all of a product's
policies, deduplicates, applies name = slug-with-underscores-as-
spaces transformation
- Products with no policy entitlements get NULL (not [])
- Manually-set catalog values round-trip
- Schema is otherwise FK-clean post-migration
Test count: 78 (was 77; +1 for migration_0014_backfills_*).
Phase 2 (SDK updates + integration doc + side-by-side card-grid
policy authoring UI) ships in follow-up commits before v0.2.0:8.
|
||
|
|
927ac2be53 |
UX polish — duration, preview button, Select state, dropdown current, switch action
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. |
||
|
|
54f7ea08b5 |
P1 — change-tier UX, Zaprite webhook copy, self-tier guard, Lightning copy
Bundle of bugfixes from the P1 testing batch. None individually
huge; together they close several "tested it, hit a sharp edge"
items.
1. Change-tier modal — kill the paid path from UI
The Apply-as-comp toggle is gone. Admin tier changes always
apply as comp now. The reasoning (per Grant's testing): admin
tier changes are operator-driven, payment has either already
happened off-rails or it's a comp; the "admin generates
invoice and forwards URL" flow is a tiny niche that just
produces orphan invoices when the modal gets dismissed.
Buyers who want to pay use the SDK's /v1/upgrade.
The API path is unchanged for back-compat with scripted
operators (skip_payment defaults to true here).
2. Change-tier modal — downgrade detection + warning banner
Detects target.tier_rank < current.tier_rank (or price-diff
when ranks aren't set), renders a yellow warning card listing
the entitlements the buyer is about to lose, and confirms via
browser dialog before submit. Operator sees what they're
doing.
3. Self-tier guard on admin change-tier
POST /v1/admin/licenses/<id>/change-tier rejects when <id>
is the daemon's own self_license. Avoids the recursion Grant
hit when trying to downgrade himself: the on-disk signed key
is the source-of-truth at boot, so the DB tier_change just
produces a half-applied state. Error message points at the
right paths (re-mint via master Keysat OR rename
/data/keysat-license.txt for testing). With the P0 self-tier
live-refresh in place the recursion is now fully resolved
anyway, but the guard is good belt-and-suspenders for
operator clarity.
4. Zaprite webhook — full URL in copy + persistent action
- The Connect Zaprite action now shows the EXACT
https://your-keysat-url/v1/zaprite/webhook URL to paste
into Zaprite's dashboard. Previous copy showed a
placeholder "<your Keysat public URL>/...", which Zaprite's
form rejects (it requires full https://). Daemon's
/v1/admin/zaprite/connect now returns webhook_url; the
action displays it.
- New "Show Zaprite webhook setup" StartOS Action — operators
who skipped the step on first connect, or who lost the
output, can run this any time and get the URL again.
- Full explainer of what webhooks unlock vs polling-only:
"without webhooks, Keysat polls /v1/orders every 60s, so
license issuance lags settle by up to a minute; with
webhooks, ~1s." Lives on /v1/admin/zaprite/status response
as `webhook_explainer` + in the action's display text.
5. Connect-while-connected short-circuit
POST /v1/admin/zaprite/connect now returns 409 Conflict with a
clear "already connected — disconnect first" message instead
of silently overwriting an existing config. (BTCPay's
start_connect already had this guard since the durable
provider switch work.)
6. Lightning vs on-chain copy on the wait page
/thank-you was hard-coded to "next block confirms" — wrong
for Lightning payments (instant) and confusing in the common
case where buyers paid via Lightning and saw a "waiting for
block confirmation" message. Updated to: "Lightning settles
in seconds; on-chain typically settles in 10-20 minutes (one
block confirmation)." Method-aware copy (parsed from the
provider's invoice payload) is a deeper fix but out of scope
here — this gets the operator-facing accuracy right today.
Test count unchanged; all 77 still passing.
|
||
|
|
2fbd36fac6 |
P0 — recurring + trial + renewal-webhook + self-tier live refresh
Five fixes that were all blocking real-world use of the recurring
+ tier-upgrade features. All deeply related; bundling them into one
commit because they share data flow and would be silly to land
piecemeal.
1. Subscription row created on recurring purchase
issue_license_for_invoice now calls
subscriptions::create_subscription whenever the resolved policy
has is_recurring=1. Previously the licenses row was inserted but
no corresponding subscription, so the renewal worker never picked
it up — buying a recurring policy was silently equivalent to a
one-shot purchase. Idempotent against webhook re-delivery.
2. trial_days actually does something
/v1/purchase short-circuits BEFORE pricing/discount logic when
the chosen policy has is_recurring=1 AND trial_days > 0:
synthesizes a free invoice via repo::create_free_invoice,
issues the license inline with expires_at = now + trial_days,
creates the subscription with next_renewal_at = trial_end so the
renewal worker fires the FIRST paid invoice when the trial ends.
Buyer pays nothing today. Discount codes are deliberately
ignored on trial purchases (free + discount = no-op).
3. Trial license carries the TRIAL flag
In the regular webhook issuance path, is_trial is now set
whenever (policy.is_trial OR (is_recurring AND trial_days > 0)),
so the signed payload's TRIAL bit reflects what the buyer is
actually getting and SDK consumers can render
"trial — N days remaining" correctly.
4. Renewal-pending webhook payload enriched
subscription.renewal_pending now includes buyer_email (looked up
from the license), product_id, policy_id, cycle_start_at,
cycle_end_at, due_at, and is_first_paid_cycle. With these the
operator's webhook receiver has everything it needs to render
"your free trial is ending" vs "your monthly renewal is due"
emails and forward the checkout_url to the buyer. Without this
payload upgrade, renewal invoices were created server-side but
no one knew about them.
5. Self-tier live refresh
New license_self::refresh_self_tier_from_db re-reads the
daemon's own license row from the local DB and rebuilds
state.self_tier with LIVE entitlements (not the immutable
signed-payload entitlements). Without this, an admin Change
Tier on the daemon's own license never propagates — the
running process keeps showing whatever tier was baked in at
key-signing time, even though the DB row says otherwise.
Wired to run:
- Once at boot, immediately after check_at_boot (so any tier
change between two daemon runs takes effect on next start)
- Every hour thereafter (background task in main.rs)
- On demand via POST /v1/admin/self-license/refresh, exposed
for operators who don't want to wait for the next tick
For master Keysat (the one selling licenses) the refresh
query is local. Non-master operators in v0.3+ can extend this
to call upstream `/v1/validate`. For v0.2.x, local-DB-only
resolves your testing case (downgrade yourself, click refresh,
sidebar updates, gate tests work).
6. Buy page CTA reflects trial
When the selected tier has is_recurring=1 and trial_days > 0,
the price card renders "FREE for N days" and the button reads
"Start N-day free trial" instead of "Pay with Bitcoin". Buyer
knows they aren't being charged today.
7. Invoice model gains listed_currency + listed_value
Already in the DB schema (migration 0010); the Rust model just
wasn't reading them. Needed by #1 to set the subscription's
listed_value correctly for fiat-priced recurring policies.
Test count unchanged (77 passing). The recurring-tests-still-pass
proof point isn't the test suite (these are behavioral changes
above the renewal-worker tests' scope) — it's that the renewal
worker tests construct subscriptions explicitly and don't go
through the purchase path that was broken.
|
||
|
|
c5d716a6d4 |
Tier upgrades Phase 4 — admin force-change + renewal-worker hook
Closes the operator side of TIER_UPGRADES_DESIGN.md. With this in,
operators can force-change any license to any policy under the same
product (sideways, cross-NULL-rank, perpetual downgrades all
allowed) — and scheduled tier changes (e.g. recurring downgrades
recorded with future effective_at) actually fire at cycle boundaries.
New endpoint:
- POST /v1/admin/licenses/:id/change-tier
Body: { to_policy_slug, skip_payment: bool, reason?: string }
skip_payment=true (comp upgrade / support fix-up): apply
immediately, write a tier_changes row with proration=0 and
invoice_id=NULL, fire the license.tier_changed webhook, audit-log
with actor=admin_api_key.
skip_payment=false: same as buyer's /v1/upgrade — create a
provider invoice for the prorated charge, persist the local
invoice + a tier_changes row tied to it, return the checkout URL.
Operator forwards it to the buyer through whatever channel they
use. Webhook applies on settle.
Bypasses ladder rules entirely (sideways, perpetual downgrade,
recurring → perpetual all OK). Same-product / different-policy /
active-target checks still apply.
QuoteMode refactor (src/upgrades.rs):
- compute_upgrade_quote now takes QuoteMode::{Buyer, Admin}.
- Buyer mode = strict ladder rules (per Phase 2).
- Admin mode = bypass ladder + downgrade gates; infer direction
from rank-diff if both ranked, else from price-diff.
- Buyer endpoint passes Buyer; admin endpoint passes Admin.
Renewal-worker hook (src/subscriptions.rs):
- Before pricing each renewal cycle, the worker calls
apply_pending_tier_changes(state, sub). This finds tier_changes
rows for the sub's license where effective_at <= now AND
invoice_id IS NULL AND license.policy_id != to_policy_id (i.e.
scheduled comp/admin changes that haven't been applied yet).
Each pending change is applied via apply_tier_change (which
also rewrites the sub's policy_id / listed_value / period_days).
After applying, the worker re-fetches the sub and prices the
next invoice at the NEW tier's listed_value.
- This is what makes recurring downgrades actually take effect at
the cycle boundary (admin records "Pro → Standard at next
renewal", the worker applies it, the new invoice bills at
Standard's price).
- Idempotent: re-running the hook on a license already on the
target tier finds zero pending rows (the policy_id != check
filters them out).
Tests (+5, total now 77):
- admin_change_tier_skip_payment_applies_immediately — comp path
flips license + writes tier_change row with no invoice
- admin_change_tier_allows_perpetual_downgrade — the case the
buyer endpoint rejects with 400 "admin-only"
- admin_change_tier_rejects_zero_charge_paid_path — sideways
attempt with skip_payment=false hints at switching to true
- admin_change_tier_requires_admin_token — 401 without auth
- renewal_worker_applies_pending_tier_change_before_billing —
the headline behavior: a pending downgrade tier_change with
effective_at=now causes the next renewal to bill at the new
(lower) tier's price, NOT the old one. Uses a CapturingProvider
mock that stashes the last sat amount it saw so the assertion
is on what the worker actually billed.
|
||
|
|
b7fa6c7dae |
Tier upgrades Phase 3 — buyer-facing HTTP endpoints
Closes the buyer self-service tier-upgrade loop. With this in,
SDKs can wire an "Upgrade to Pro" button inside the operator's
app and the daemon handles quote → invoice → settle → apply
without operator involvement.
New endpoints (auth via signed license_key in body, same model
as /v1/recover and /v1/subscriptions/cancel — no admin token,
no cookie):
- POST /v1/upgrade-quote — read-only quote. "If I upgraded to
<tier>, what would I owe right now,
when do entitlements take effect,
what will the next renewal charge?"
- POST /v1/upgrade — buyer commits. Daemon recomputes the
quote (don't trust client shaping),
rejects 0-charge upgrades (admin path
only), creates a provider invoice for
the prorated charge in the listed
currency converted to sats, persists
the local invoice + a tier_changes
row tying them together, returns the
checkout URL.
Webhook handler change (src/api/webhook.rs):
- On invoice settle, BEFORE the subscription / license-issuance
branches, look up the invoice in tier_changes via
upgrades::get_tier_change_by_invoice. If present, run the
apply path: mutate the existing license's policy_id +
entitlements + max_machines + grace + expires_at, mutate any
tied subscription's policy_id + listed_value + period_days
(so future renewals charge the new tier), audit, fire the new
`license.tier_changed` webhook event, ack 200.
- Idempotent: re-delivered webhook on an already-applied
tier change is a no-op (license.policy_id == target.id check).
- Critically: the existing license_id is preserved. Buyers
keep the same signed key; on next online validation their
app sees the new entitlements. No new license is issued.
Phase 3 scope deliberately excludes:
- Buyer-initiated DOWNGRADES. compute_upgrade_quote already
returns 0-charge quotes for recurring downgrades (effective at
next_renewal_at), but applying that at the cycle boundary
needs renewal-worker integration. Phase 4 lands the admin
endpoint AND the worker hook in one go. For v0.2.x the buyer
endpoint rejects with 400 "admin-only".
- Admin force-change (POST /v1/admin/licenses/:id/change-tier).
Phase 4.
Tests (+6, total now 72):
- upgrade_quote_returns_perpetual_difference (Standard $25 →
Pro $75 = $50 = 5000 cents quote, "immediate" effective)
- upgrade_quote_rejects_garbage_key (401, doesn't leak whether
the target slug exists)
- upgrade_quote_rejects_unknown_target_policy (404)
- upgrade_start_creates_invoice_and_tier_change_row (verifies
the tier_changes row is written tied to the new invoice; the
license is NOT yet on Pro until settle)
- webhook_settle_on_tier_change_applies_instead_of_issuing
(full end-to-end: settle webhook fires → license flips to Pro
+ Pro entitlements appear; license count stays at 1, NO new
license issued; re-delivery idempotent)
- upgrade_endpoint_rejects_buyer_downgrade (400 "admin-only" —
the clear-message path the quote function intercepts with;
Phase 4 will introduce a separate buyer-downgrade path)
|
||
|
|
f8affdb11f |
Tier upgrades Phase 2 — quote logic + apply step
Builds on
|
||
|
|
938eedc99f |
Mobile responsiveness pass — buy / recover / thank-you
The recurring-subs work just added new tier-card content (cadence line + trial banner + /mo suffix), so a quick pass on the three buyer-facing pages was timely. Targeted, CSS-only changes. Buy page (`/buy/<slug>`): - h1 uses clamp(28px, 7vw, 42px) so it scales smoothly from phones to desktop without cliff-edge breakpoints. The fixed 42px was cramping 360-380px viewports. - New @media (max-width:480px) breakpoint tightens the outer rhythm: topbar padding, wrap margin, cert padding, price size, tier-card padding, etc. The desktop 48px outer + 32px cert padding ate too much of a small viewport. - Form input font-size pinned to 16px on mobile so iOS Safari doesn't auto-zoom when the buyer taps the email or discount field. (iOS zooms on any <16px input, which interrupts the buy flow.) - Tier picker already had a 560px breakpoint dropping to 1-column; unchanged. Recovery page (`/recover`): - Default input/button font-size raised to 16px (iOS zoom fix). - New @media (max-width:480px) breakpoint reduces outer body padding (48px → 24px) and main padding (32px → 22px), tightens h1 + label, and bumps button padding for thumb-friendly tap targets. Thank-you page (`/thank-you`): - Adds a @media (max-width:480px) block — previously it had zero breakpoints. Mirrors the buy-page pattern: tighter topbar, wrap margin, card padding, h1 fluid scaling, lede + footer sizing. Admin UI is operator-side and not addressed in this pass. Could be revisited if operators report mobile pain points; for now the buyer-facing surface is the priority because that's where buyers actually arrive on phones. |
||
|
|
5d7f68fef8 |
Recurring subs Phase 6 — cancellation flow (admin + buyer self-serve)
Closes the recurring-subs feature loop: operators can cancel subs from
the admin UI, buyers can self-cancel by submitting their signed
license key. Cancellation is non-destructive — the license stays
valid through end-of-cycle, the renewal worker just stops creating
new invoices because its WHERE filter excludes status='cancelled'.
New API
- GET /v1/admin/subscriptions — list (filter: status=...)
- POST /v1/admin/subscriptions/:id/cancel — operator cancel (audited)
- POST /v1/subscriptions/cancel — buyer self-service; auth
via license_key in body,
verified by signature
Repo helpers (src/subscriptions.rs)
- get_subscription_by_id
- get_subscription_by_license_id (1:1 unique on license_id, used by
buyer self-service)
- list_subscriptions(status_filter, limit)
- cancel_subscription (idempotent UPDATE, returns whether
it actually transitioned)
Behavior details
- Both endpoints fire `subscription.cancelled` webhook with
actor=admin/buyer so operators can distinguish self-service.
- Audit log differentiates by actor_kind: 'admin_api_key' vs
'buyer_license_key'.
- Buyer endpoint returns 401 (not 404) on bad/wrong key so a probe
can't enumerate which licenses have active subs.
- Buyer endpoint returns 401 on revoked or suspended licenses too —
same reason.
- Admin endpoint returns 200 with `{already: <prior_state>}` on
re-cancel (idempotency); 404 on unknown sub.
Tests (+4, total now 57)
- admin_cancel_subscription_happy_path: full flow + DB invariants +
audit row + idempotency
- admin_cancel_unknown_subscription_404s
- buyer_cancel_subscription_via_license_key: full flow + actor_kind
- buyer_cancel_rejects_garbage_key: 401 not 404
Admin UI for the cancel button + subscriptions tab lands in a
follow-up commit (kept this one to the API surface so it's reviewable
in isolation).
|
||
|
|
c301eacfaa |
Recurring subs Phase 4 — admin UI + buy-page rendering + Pro-tier gate
Phase 4 surfaces the recurring-subscription schema (migration 0011) and
renewal-worker (Phase 2, commit
|
||
|
|
7007bf8204 |
Recurring subs Phase 2 — renewal worker (committed, not published)
Implements the renewal lifecycle from RECURRING_SUBSCRIPTIONS_DESIGN.md
phase 2. Operators don't see this yet (no admin UI); the worker
only acts on subscriptions that exist in the schema, and creating
subscription rows still requires direct DB insert. Phase 4 (admin
UI) wires the buyer-facing surface that creates them.
src/subscriptions.rs (new module, ~450 LOC):
- find_due_renewals: subs with status active|past_due whose
next_renewal_at has passed and consecutive_failures < cap
- find_lapsing_subscriptions: past_due subs whose
(next_renewal_at + grace_period_days) is in the past
- mark_lapsed / mark_active_after_settle / mark_renewal_failed:
state-transition helpers
- create_subscription: atomic create-sub + first-cycle invoice
(called by purchase flow when policy.is_recurring; not yet
wired — that's a separate phase)
- on_invoice_settled: helper for webhook handler to flip a sub
from past_due back to active and dispatch subscription.renewed
- find_subscription_for_invoice: lookup helper
- tick: 60s sweep, picks up to 25 due renewals + lapse sweep
- spawn: long-lived background task, mirrors webhooks::spawn_delivery_worker
Renewal flow per due sub:
1. Convert listed_value to sats via rates::convert_to_sats
(identity for SAT subs; live rate fetcher for USD/EUR — per
MULTI_CURRENCY_DESIGN.md "USD-stable / re-quote each cycle"
decision).
2. Get the active payment provider, call create_invoice with
the same trait surface used by one-shot purchases. Works
against BTCPay or Zaprite or any future provider.
3. Persist the local invoice row carrying the rate audit
(listed_currency / listed_value / exchange_rate_centibps /
exchange_rate_source). For SAT subs, rate fields are NULL
(identity conversion isn't worth recording).
4. Insert subscription_invoices linking the invoice to the sub
with monotonic cycle_number.
5. Update sub: status → past_due, next_renewal_at → end of new
cycle, last_renewal_attempt_at → now.
6. Dispatch subscription.renewal_pending webhook to the operator.
On settle (webhook handler): if the invoice is linked via
subscription_invoices, flip sub → active, reset
consecutive_failures to 0, dispatch subscription.renewed.
Failure path: increment consecutive_failures, push next_renewal_at
out by exponential backoff (5min → 30min → 2h → 6h → 12h, capped
at 5 failures ≈ 24h of retries before the worker stops trying).
Operator can see stuck subs via the upcoming admin UI; for now
they show up in the audit log via webhook deliveries.
Lapse path: separate sweep finds past_due subs whose
(next_renewal_at + policy.grace_period_days) is past now, flips
to lapsed, dispatches subscription.lapsed.
Wired into:
- src/lib.rs: pub mod subscriptions
- src/main.rs: subscriptions::spawn(state.clone()) alongside
reconcile + webhooks + analytics
- src/api/webhook.rs: settle path now calls
subscriptions::on_invoice_settled before license issuance —
ordering matters because first-cycle subs create both a sub
row AND a license; we want the sub state correct on the way
to the license-issuance branch
Test: 7 integration tests in tests/subscriptions.rs. Drives the
worker against a MockProvider with fail-on-demand semantics:
- renewal_worker_creates_invoice_for_sat_priced_due_sub: SAT sub
charges listed_value sats verbatim, no rate audit, sub goes
active → past_due, subscription_invoices gets a new cycle row
- renewal_worker_requotes_rate_for_fiat_priced_sub: $25 USD at
pinned $50k/BTC = exactly 50,000 sats; rate audit pinned on
invoice; centibps encoded correctly
- renewal_worker_backs_off_on_failure: failed create_invoice →
consecutive_failures = 1, no invoice created, sub → past_due
- renewal_worker_stops_retrying_at_max_failures: pre-set failures
= MAX, tick is a no-op for that sub
- lapse_sweep_flips_past_due_after_grace: 15-day-old past_due
with grace=7 → lapsed
- settle_webhook_flips_sub_back_to_active: tick creates renewal,
simulate settle, on_invoice_settled flips sub back to active
- tick_is_no_op_when_nothing_due: empty fixture, tick is safe
Test count: 49 (was 42; +7).
NOT bumping version. The recurring-subs feature isn't operator-
visible until phases 4+5 (admin UI for creating recurring
policies + buy page rendering for "$25/month"). Schema is in,
worker runs, but nothing creates subs yet — so this commit
ships dormant.
|
||
|
|
ec2b21d8f7 |
v0.2.0:3 — durable payment-provider switching (Option B)
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.
|
||
|
|
9eba309a8f |
v0.2.0:2 — Zaprite payment provider + recurring subscriptions schema foundation
This release adds Zaprite as an alternative to BTCPay. Operators can now choose between two payment rails: - BTCPay: Bitcoin-only, you run the BTCPay Server yourself - Zaprite: Bitcoin + fiat cards (USD/EUR via Stripe/Square), brokered by Zaprite, settles to your connected wallets Only one is active at a time per Keysat instance. Switching requires Disconnect → Connect; existing license keys are unaffected. Future v0.3 work routes per-policy choice (e.g., "free tier via Zaprite, paid tier via BTCPay") if operators want both, but for v0.2.0:2 it's either-or. What's in this release: **Migration 0011 — recurring subscriptions schema (dormant).** Adds `subscriptions` and `subscription_invoices` tables, plus `is_recurring`/`renewal_period_days`/`grace_period_days` (default 7)/ `trial_days` (default 0) on policies. No daemon code uses these yet — phases 2-6 of RECURRING_SUBSCRIPTIONS_DESIGN.md land in follow-up commits. Migration regression test covers the additive contract against populated data. **Migration 0012 — zaprite_config.** Singleton-row table for the operator's Zaprite API key + base URL + recorded webhook id. Mirrors btcpay_config from migration 0002. **ZapriteProvider implementation.** New module at src/payment/zaprite/ with client.rs (HTTP, Bearer auth), config.rs (DB persistence), provider.rs (PaymentProvider trait impl). Maps Zaprite's currency enum (BTC/USD/EUR) to/from the Money type; maps Zaprite's order status enum (PENDING/PROCESSING/PAID/COMPLETE/ OVERPAID/UNDERPAID) to ProviderInvoiceStatus. **Webhook security via externalUniqId round-trip.** Zaprite does NOT publish a webhook signature scheme (verified May 2026 against public OpenAPI + dashboard). Their docs explicitly designate receiver-side idempotency as the security model. Keysat's defense: attach our local invoice UUID as externalUniqId at order creation, then trust the webhook only insofar as the order id resolves to a local invoice in an expected state. Documented in detail in the payment::zaprite module-level comment + the validate_webhook docstring. **Admin endpoints.** - POST /v1/admin/zaprite/connect: validates the API key by pinging GET /v1/orders before persisting; swaps active provider atomically - POST /v1/admin/zaprite/disconnect: clears stored creds + provider - GET /v1/admin/zaprite/status: read-only connection snapshot - POST /v1/zaprite/webhook: webhook landing route (alias of the existing /v1/btcpay/webhook handler since validate_webhook is trait-level) **StartOS Actions** under a new "Zaprite" group: Connect Zaprite, Check Zaprite connection, Disconnect Zaprite. Operator pastes the API key into a masked input; daemon validates + saves. **Tests.** Two new in tests/api.rs (zaprite_webhook_event_parsing covers the full event-type mapping + missing-id rejection + malformed-JSON rejection; zaprite_provider_kind pins the identification). Migration regression test for 0011. Test count grows 39 → 41. Operators on BTCPay see no change. Operators wanting Zaprite go through the StartOS Actions tab → Connect Zaprite, paste their API key, register a webhook in Zaprite's dashboard pointing at their public Keysat URL + /v1/zaprite/webhook. Recurring subscriptions are NOT yet operator-visible — schema only in this release. Daemon-code that uses the subscriptions tables (renewal worker, validate-hot-path subscription branch, admin UI) lands in subsequent commits per the design doc's phased plan. |
||
|
|
622fa77e29 |
v0.2.0:1 — drop FOUNDERS50 placeholder from buy-page discount input
Per operator feedback: the discount-code field on /buy/<slug> was showing 'FOUNDERS50' as a placeholder, which confused buyers (some tried it as a real code, some assumed Keysat shipped a default discount). Empty placeholder now; buyers paste their actual code. No semantic change. Wrapper-only revision; daemon binary unchanged beyond the embedded HTML template. |
||
|
|
45e0cd2bd1 |
Edit-product currency support — operators can switch SAT ↔ USD/EUR in place
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. |
||
|
|
0dcae66e05 |
SPA polish — compact analytics opt-in, discount-code currency picker, fiat tier rendering
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).
|
||
|
|
d8aa9c22b9 |
Multi-currency Phases 3, 5, 6 — buy page, invoice rate recording, discount currency
Phase 5 (invoice records the rate):
- repo::create_invoice_with_currency takes the listed currency,
listed value, exchange_rate_centibps, and exchange_rate_source as
optional params; create_invoice (the legacy form) becomes a thin
wrapper that passes None for all four. SAT-priced flows are
unchanged.
- purchase::start now branches on product.price_currency: SAT keeps
the existing path; USD/EUR calls rates::convert_to_sats and pins
the listed price + rate to the local invoice row for audit. The
buyer is still billed in BTC (BTCPay invoice is sat-denominated)
but the audit trail records what they SAW vs what they were
charged.
- Test paid_purchase_in_usd_records_listed_currency_and_rate seeds
a manual rate pin ($50k/BTC), creates a USD-priced product
($49.00), runs through purchase, asserts the invoice row carries
listed_currency='USD', listed_value=4900, rate_centibps=
500_000_000, source='manual_pin', amount_sats=98_000.
Phase 3 (buy page renders fiat):
- Server-rendered initial price respects product.price_currency:
USD products show "49.00 USD" (cents converted to display dollars)
instead of sats. Tier-picker JS still formats per-tier prices in
sats — that's a v0.3 polish when we plumb the rate into the JS
render path. Most operators ship single-policy products at first,
so the static initial render is the high-leverage piece.
Phase 6 (currency-aware discount codes):
- POST /v1/admin/discount-codes accepts optional `discount_currency`
field ('SAT' default, 'USD', 'EUR'). Whitelisted in the handler.
- repo::create_discount_code is now a thin wrapper around
create_discount_code_with_currency; the new helper persists
discount_currency to the column added in 0010. Existing SAT-only
codes keep working unchanged.
Test count: 37 (was 36; +1 paid_purchase_in_usd test).
Multi-currency design phases 1-6 all shipped (1: schema in :48; 2:
admin UI write in :48-:49; 3: buy page; 4: rate fetcher; 5: invoice
audit; 6: discount currency). Phase 7 (recurring subscriptions
re-quote) is v0.3 territory — needs the recurring-billing scaffolding
from Zaprite first.
|
||
|
|
eb885502ba |
Multi-currency Phase 4 — rate fetcher with Kraken/Coinbase/CoinGecko fallback
src/rates.rs adds an in-memory rate cache (60s TTL) with a 3-source fallback chain. AppState gains `rates: Arc<RateCache>`. Manual pins via the settings table override the chain — used by tests for deterministic conversions and by operators during maintenance windows. Admin endpoints: - GET /v1/admin/rates: cache snapshot - POST /v1/admin/rates/refresh: force re-fetch (audit-logged) Two new tests (network-free, manual-pin path): - rate_cache_honors_manual_pin_from_settings - admin_rates_endpoint_reflects_manual_pin Test count: 36 (was 34). |
||
|
|
356d17fdde |
Multi-currency Phase 2 — admin write path (currency picker)
Backend:
- POST /v1/admin/products accepts both forms:
- legacy: { price_sats: 50000 }
- typed: { price_currency: 'USD', price_value: 4900 }
Whitelist enforced (SAT|USD|EUR). Mismatched legacy + typed → 400
to catch half-migrated clients sending stale price_sats alongside
fresh price_value.
- repo::create_product_with_currency: SAT → dual-write price_sats =
price_value; USD/EUR → price_sats = 0 until first invoice creation
triggers a rate lookup (Phase 4 + 5).
- Test admin_create_product_accepts_legacy_and_typed_currency_forms
pins 6 happy/sad paths.
Frontend (Products page):
- Create-product form has a currency picker (sats / USD / EUR).
Picker swaps the unit hint + step in place.
- Decimal entry on USD/EUR is converted to cents on the way out.
- Products table renders prices via formatProductPrice(): USD
products show "$49.00" with optional "≈ 75k sats" hint.
Test count: 34 (was 33).
|
||
|
|
d827b1aaab |
Opt-in community analytics + admin UI surface
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). |
||
|
|
f6ba1c160e |
Buyer self-service recovery + db-info admin endpoint
Two operator-facing additions, both addressing risks we'd flagged
earlier in the v0.2 plan but hadn't shipped.
**POST /v1/recover (+ GET /recover HTML form).** Lets a buyer who
lost their license key re-derive it themselves by presenting their
invoice id + the email they paid with. Until now, the recovery
flow was "DM the operator with your invoice id and they re-send" —
operator-time scaling badly. With this, the buyer self-serves and
the operator never has to know.
The endpoint takes (invoice_id, email), case-insensitive on email.
Returns a generic 404 on any mismatch — does NOT distinguish
"invoice not found" from "wrong email" so an attacker can't
brute-force email addresses against a known invoice id. Per-IP
rate limited at 10 requests / minute. Audit-logged as
license.recovered with the email's SHA-256 hash so PII isn't
written to the log.
The HTML form at GET /recover is server-rendered, no JS framework,
no cookies — designed for a customer who's just had a catastrophic
failure of their primary computer and reached us from whatever
device they could find.
Test in tests/api.rs:recover_returns_license_key_for_matching_pair
exercises the happy path (case-insensitive email match), the
generic-404 paths (wrong email, missing invoice), the round-trip
(recovered key validates via /v1/validate), and the audit-log
write.
**GET /v1/admin/db-info.** Cheap insurance against the
catastrophic-loss risk: /data/keysat.db is a single SQLite file,
losing it invalidates every license ever issued. StartOS's backup
machinery handles snapshotting; this endpoint gives operators a
sanity-check surface they didn't have before:
- DB file path + on-disk size
- last-write timestamp (max across audit_log, invoices, licenses)
- row counts for products, policies, licenses (total + active),
invoices (total + settled), machines (active), discount codes,
audit log entries
Doesn't report when StartOS last backed it up — the daemon has no
visibility into the host's snapshot subsystem. What it gives the
operator is a "I expected ~50 licenses and I see ~50 licenses; the
file is N MB; the last write was 6 hours ago" check.
Test count: 31 (was 30; +1 for the recover test).
|
||
|
|
f9ef1a854c |
Webhook DLQ — list failed deliveries and manually retry
Closes the silent-loss hole in outbound webhook delivery. The worker
in src/webhooks.rs retries failed deliveries with exponential backoff
up to 10 attempts, then sets next_attempt_at = NULL and walks away.
Pre-this-commit, those "dead-lettered" rows sat in webhook_deliveries
forever with no surface for the operator to discover, inspect, or
recover from them — a subscriber that was down for >6h during a
license-issuance burst would silently lose those events forever.
What's new:
- repo::DeliveryStatusFilter — enum with parse() so query strings
map cleanly to SQL predicates.
- repo::list_deliveries — endpoint_id + status + limit, newest first.
- repo::requeue_delivery — resets attempt_count=0, clears delivered_at
and last_error, sets next_attempt_at=now. The worker picks it up on
the next 5s tick.
- src/api/webhook_deliveries.rs — admin module with two handlers:
- GET /v1/admin/webhook-deliveries?endpoint_id=…&status=…&limit=…
- POST /v1/admin/webhook-deliveries/:id/retry (audit-logged as
webhook_delivery.retry; 404 on missing id)
- Routes registered in src/api/mod.rs alongside the existing
webhook_endpoints CRUD.
- tests/api.rs gains webhook_dlq_lists_failed_and_retry_requeues:
seeds three deliveries directly via SQL (one each: delivered,
pending, dead-lettered), exercises the list filter, runs the retry,
asserts the row migrates from failed→pending, audit row is written,
404 on bad id, 400 on bad status filter.
Worker code is unchanged. The DLQ is operator-actionable infrastructure
on top of the existing retry semantics.
Test count: 23 (9 unit + 4 migration + 10 API), up from 22.
|
||
|
|
e2b296ce29 |
Migrate purchase::start onto PaymentProvider trait + paid-purchase test
Drops the legacy compat path. `purchase::start` now calls
`state.payment_provider().await?.create_invoice(CreateInvoiceParams {
...})` instead of `state.btcpay_client().await?.create_invoice(...)`.
Provider-specific concerns (BTCPay's checkout-URL rewriting from the
internal Docker hostname to the public domain, metadata enrichment
with `orderId` / `source`) move inside the BtcpayProvider impl where
they belong; the same code path now serves any future provider
(Zaprite, etc.) without fork/copy.
URL rewriting is removed from the caller (no longer needs to know
which provider's URLs to rewrite or how). The
`crate::payment::btcpay::rewrite_to_public` function stays on the
provider impl; pubpath unchanged.
Adds `paid_purchase_creates_invoice_via_provider` integration test —
previously deferred per :42's release notes because the compat path
prevented MockPaymentProvider from substituting. Now the mock works
through the same call site as production. Verifies:
- daemon delegates invoice creation to the provider
- returned provider_invoice_id is stamped on the local invoice row
- checkout_url is what the provider returned
- no license issued at this stage (that's the webhook's job)
Test count: 22 (9 unit + 4 migration + 9 API).
|