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
+5
View File
@@ -472,6 +472,11 @@ fn row_to_invoice(row: sqlx::sqlite::SqliteRow) -> Invoice {
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
policy_id: row.try_get("policy_id").ok().flatten(),
listed_currency: row
.try_get::<Option<String>, _>("listed_currency")
.ok()
.flatten(),
listed_value: row.try_get::<Option<i64>, _>("listed_value").ok().flatten(),
}
}