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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user