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.
This commit is contained in:
Grant
2026-05-09 13:52:47 -05:00
parent 735461b3ef
commit 2fbd36fac6
10 changed files with 408 additions and 6 deletions
+28 -1
View File
@@ -717,19 +717,46 @@ async fn renew_one(state: &AppState, sub: &Subscription) -> Result<()> {
.context("UPDATE subscriptions on renewal create")?;
// 9. Webhook event: operator's app gets notified that a
// renewal invoice exists and the buyer needs to pay.
// renewal invoice exists and the buyer needs to pay. The
// operator's webhook receiver renders an email / push /
// in-app notification with `checkout_url` and sends it to
// `buyer_email` (Keysat does not email buyers itself —
// operator-driven communication, same as license issuance).
//
// `is_first_paid_cycle` lets operators distinguish "your
// free trial is ending, here's the first real charge" from
// "your monthly renewal is due" — different copy is usually
// appropriate.
let buyer_email: Option<String> = sqlx::query_scalar(
"SELECT buyer_email FROM licenses WHERE id = ?",
)
.bind(&sub.license_id)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
let is_first_paid_cycle = next_cycle_num == 2;
crate::webhooks::dispatch(
state,
"subscription.renewal_pending",
&json!({
"subscription_id": sub.id,
"license_id": sub.license_id,
"product_id": sub.product_id,
"policy_id": sub.policy_id,
"invoice_id": internal_invoice_id,
"checkout_url": handle.checkout_url,
"amount_sats": amount_sats,
"listed_currency": sub.listed_currency,
"listed_value": sub.listed_value,
"cycle_number": next_cycle_num,
"cycle_start_at": cycle_start.to_rfc3339(),
"cycle_end_at": cycle_end.to_rfc3339(),
"due_at": cycle_end.to_rfc3339(),
"buyer_email": buyer_email,
"is_first_paid_cycle": is_first_paid_cycle,
}),
)
.await;