b7fa6c7dae6e8d634715ec9acf60e9ee3d1cd926
4 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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)
|
||
|
|
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.
|
||
|
|
beedd07f07 | v0.1.0:25–40 — tier model, edit forms, force-delete, license counts, migration 0009 (and hotfix); KEYSAT_INTEGRATION.md merged with downstream-LLM revisions | ||
|
|
6ac118ae70 |
v0.1.0:24 — Keysat licensing service end-to-end
Daemon, StartOS wrapper, admin SPA, public buy/thank-you pages, discount codes, free-license redemption, Apply-discount UX, self-licensing, and v0.1.0 release notes. |