Files
keysat/licensing-service/tests/upgrades.rs
T
Grant 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.
2026-05-08 20:12:44 -05:00

995 lines
28 KiB
Rust

//! Integration tests for tier upgrades — the quote logic + apply
//! step that lives in `src/upgrades.rs`. Phase 2 of
//! TIER_UPGRADES_DESIGN.md. No HTTP layer yet (Phase 3); these
//! tests exercise the pure module API.
use anyhow::Result;
use chrono::Utc;
use keysat::api::AppState;
use keysat::config::Config;
use keysat::db::repo;
use keysat::license_self::Tier;
use keysat::upgrades::{
apply_tier_change, compute_upgrade_quote, list_tier_changes_for_license,
record_tier_change, EffectiveAt, QuoteMode, TierDirection,
};
use serde_json::json;
use sqlx::sqlite::{
SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteSynchronous,
};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use tempfile::NamedTempFile;
use tokio::sync::RwLock;
use uuid::Uuid;
const TEST_ADMIN_KEY: &str = "test_admin_api_key_with_at_least_32_chars_present";
async fn make_state() -> (AppState, NamedTempFile) {
let tmp = NamedTempFile::new().expect("tempfile");
let url = format!("sqlite://{}", tmp.path().display());
let opts = SqliteConnectOptions::from_str(&url)
.expect("parse url")
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.foreign_keys(true)
.busy_timeout(Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(2)
.connect_with(opts)
.await
.expect("connect");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrations");
let keypair = keysat::crypto::keys::load_or_generate(&pool)
.await
.expect("keypair");
let cfg = Config {
bind: "127.0.0.1:0".parse().unwrap(),
db_path: PathBuf::from(":memory:"),
admin_api_key: TEST_ADMIN_KEY.to_string(),
btcpay_url: "http://btcpay.test".to_string(),
btcpay_browser_url: None,
btcpay_public_url: None,
btcpay_api_key: None,
btcpay_store_id: None,
btcpay_webhook_secret: None,
public_base_url: "http://keysat.test".to_string(),
operator_name: None,
};
let state = AppState {
db: pool,
keypair: Arc::new(keypair),
payment: Arc::new(RwLock::new(None)),
config: Arc::new(cfg),
self_tier: Arc::new(RwLock::new(Tier::Unlicensed {
reason: "test".into(),
})),
rates: keysat::rates::RateCache::new(),
};
(state, tmp)
}
/// Seed a USD-priced product, two perpetual policies (Standard
/// rank 1 / $25, Pro rank 2 / $75), a license currently on
/// Standard. Returns (license_id, standard_policy_id, pro_policy_id).
async fn seed_perpetual_ladder(state: &AppState) -> (String, String, String) {
let product = repo::create_product(
&state.db,
"perp-ladder",
"Perpetual Ladder",
"",
25_00, // $25.00 (cents); price_sats backfill from product create
&json!({}),
)
.await
.expect("create_product");
// Update product to USD currency. create_product hits the SAT
// default; bump it via a direct SQL UPDATE so the test setup
// doesn't require going through the multi-currency admin path.
sqlx::query(
"UPDATE products SET price_currency = 'USD', price_value = 2500 WHERE id = ?",
)
.bind(&product.id)
.execute(&state.db)
.await
.unwrap();
// Standard tier: $25 perpetual, rank 1.
let standard = repo::create_policy(
&state.db,
&product.id,
"Standard",
"standard",
0, // perpetual
0,
1,
false,
Some(2500), // $25.00 in cents
&["core".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig::off(),
Some(1),
)
.await
.expect("create standard");
// Pro tier: $75 perpetual, rank 2, more entitlements.
let pro = repo::create_policy(
&state.db,
&product.id,
"Pro",
"pro",
0,
0,
3,
false,
Some(7500), // $75.00 in cents
&["core".into(), "ai_summaries".into(), "export".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig::off(),
Some(2),
)
.await
.expect("create pro");
// Issue a license under Standard.
let license_id = Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&license_id,
&product.id,
None,
&Utc::now().to_rfc3339(),
&json!({}),
Some(&standard.id),
None, // perpetual
0,
1,
&["core".to_string()],
false,
None,
None,
)
.await
.expect("create_license");
(license_id, standard.id, pro.id)
}
#[tokio::test]
async fn perpetual_upgrade_quote_returns_flat_price_difference() {
let (state, _tmp) = make_state().await;
let (license_id, _standard_id, pro_id) = seed_perpetual_ladder(&state).await;
let license = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
let pro = repo::get_policy_by_id(&state.db, &pro_id).await.unwrap().unwrap();
let quote = compute_upgrade_quote(&state, &license, &pro, QuoteMode::Buyer).await.unwrap();
assert_eq!(quote.direction, TierDirection::Upgrade);
assert_eq!(quote.listed_currency, "USD");
// Pro $75 - Standard $25 = $50 = 5000 cents.
assert_eq!(quote.proration_charge_value, 5000);
assert_eq!(quote.effective_at, EffectiveAt::Immediate);
// Perpetual: no next-cycle charge.
assert_eq!(quote.next_renewal_charge, None);
assert_eq!(quote.next_renewal_period_days, None);
}
#[tokio::test]
async fn perpetual_downgrade_is_admin_only() {
let (state, _tmp) = make_state().await;
let (_lic, standard_id, pro_id) = seed_perpetual_ladder(&state).await;
// Re-issue a license, but on Pro this time, so we can attempt
// a Pro → Standard downgrade (which should be rejected).
let license_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let pro = repo::get_policy_by_id(&state.db, &pro_id).await.unwrap().unwrap();
repo::create_license(
&state.db,
&license_id,
&pro.product_id,
None,
&now,
&json!({}),
Some(&pro.id),
None,
0,
3,
&["core".to_string()],
false,
None,
None,
)
.await
.unwrap();
let license = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
let standard = repo::get_policy_by_id(&state.db, &standard_id)
.await
.unwrap()
.unwrap();
let err = compute_upgrade_quote(&state, &license, &standard, QuoteMode::Buyer)
.await
.expect_err("perpetual downgrade should be rejected");
let msg = format!("{err}");
assert!(
msg.contains("admin-only"),
"perpetual downgrade error must mention admin-only path: {msg}"
);
}
#[tokio::test]
async fn quote_rejects_target_with_null_tier_rank() {
let (state, _tmp) = make_state().await;
let (license_id, _, _) = seed_perpetual_ladder(&state).await;
// Make a target policy that DELIBERATELY has tier_rank = NULL.
let license = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
let unlisted = repo::create_policy(
&state.db,
&license.product_id,
"Promo",
"promo",
0,
0,
1,
false,
Some(5000),
&["core".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig::off(),
None, // out of ladder
)
.await
.unwrap();
let err = compute_upgrade_quote(&state, &license, &unlisted, QuoteMode::Buyer)
.await
.expect_err("unlisted target should be rejected");
let msg = format!("{err}");
assert!(
msg.contains("not in any tier ladder"),
"expected ladder rejection; got: {msg}"
);
}
#[tokio::test]
async fn quote_rejects_same_policy() {
let (state, _tmp) = make_state().await;
let (license_id, standard_id, _) = seed_perpetual_ladder(&state).await;
let license = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
let same = repo::get_policy_by_id(&state.db, &standard_id)
.await
.unwrap()
.unwrap();
let err = compute_upgrade_quote(&state, &license, &same, QuoteMode::Buyer)
.await
.expect_err("same-policy target should be rejected");
assert!(format!("{err}").contains("same as current"));
}
/// Recurring upgrade with the buyer halfway through a 30-day cycle.
/// The quote should bill ~half of the price diff. We assert a
/// tolerance window since "now" depends on test execution time.
#[tokio::test]
async fn recurring_upgrade_prorates_against_time_remaining() {
let (state, _tmp) = make_state().await;
let now = Utc::now();
let now_str = now.to_rfc3339();
// USD-priced product.
let product = repo::create_product(
&state.db,
"rec-ladder",
"Recurring Ladder",
"",
2500,
&json!({}),
)
.await
.unwrap();
sqlx::query(
"UPDATE products SET price_currency = 'USD', price_value = 2500 WHERE id = ?",
)
.bind(&product.id)
.execute(&state.db)
.await
.unwrap();
// Standard $25/mo monthly recurring, rank 1.
let standard = repo::create_policy(
&state.db,
&product.id,
"Standard",
"standard",
30 * 86_400,
0,
1,
false,
Some(2500),
&["core".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig {
is_recurring: true,
renewal_period_days: 30,
grace_period_days: 7,
trial_days: 0,
},
Some(1),
)
.await
.unwrap();
// Pro $75/mo monthly recurring, rank 2.
let pro = repo::create_policy(
&state.db,
&product.id,
"Pro",
"pro",
30 * 86_400,
0,
3,
false,
Some(7500),
&["core".into(), "ai_summaries".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig {
is_recurring: true,
renewal_period_days: 30,
grace_period_days: 7,
trial_days: 0,
},
Some(2),
)
.await
.unwrap();
// License + active subscription on Standard, ~15 days into a
// 30-day cycle.
let license_id = Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&license_id,
&product.id,
None,
&now_str,
&json!({}),
Some(&standard.id),
Some(&(now + chrono::Duration::days(30)).to_rfc3339()),
0,
1,
&["core".to_string()],
false,
None,
None,
)
.await
.unwrap();
let next_renewal = (now + chrono::Duration::days(15)).to_rfc3339();
sqlx::query(
"INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \
listed_currency, listed_value, status, started_at, next_renewal_at, \
consecutive_failures, created_at, updated_at) \
VALUES('sub1', ?, ?, ?, 30, 'USD', 2500, 'active', ?, ?, 0, ?, ?)",
)
.bind(&license_id)
.bind(&standard.id)
.bind(&product.id)
.bind(&now_str)
.bind(&next_renewal)
.bind(&now_str)
.bind(&now_str)
.execute(&state.db)
.await
.unwrap();
let license = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
let quote = compute_upgrade_quote(&state, &license, &pro, QuoteMode::Buyer).await.unwrap();
assert_eq!(quote.direction, TierDirection::Upgrade);
assert_eq!(quote.listed_currency, "USD");
assert_eq!(quote.next_renewal_charge, Some(7500));
assert_eq!(quote.next_renewal_period_days, Some(30));
assert_eq!(quote.effective_at, EffectiveAt::Immediate);
// Diff is $50 (5000 cents). 15 days remaining out of 30, so
// ~$25 (2500 cents). num_days() floors, so we expect 14 or 15
// days remaining depending on test-execution timing. Tolerance
// window: 2300..=2600.
assert!(
(2300..=2600).contains(&quote.proration_charge_value),
"proration should be ~half of $50 diff for ~15 days remaining; got {}",
quote.proration_charge_value
);
}
#[tokio::test]
async fn recurring_downgrade_is_zero_charge_at_next_cycle() {
let (state, _tmp) = make_state().await;
let now = Utc::now();
let now_str = now.to_rfc3339();
let product = repo::create_product(
&state.db,
"rec-down",
"Down",
"",
2500,
&json!({}),
)
.await
.unwrap();
sqlx::query(
"UPDATE products SET price_currency = 'USD', price_value = 2500 WHERE id = ?",
)
.bind(&product.id)
.execute(&state.db)
.await
.unwrap();
let standard = repo::create_policy(
&state.db,
&product.id,
"Standard",
"standard",
30 * 86_400,
0,
1,
false,
Some(2500),
&["core".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig {
is_recurring: true,
renewal_period_days: 30,
grace_period_days: 7,
trial_days: 0,
},
Some(1),
)
.await
.unwrap();
let pro = repo::create_policy(
&state.db,
&product.id,
"Pro",
"pro",
30 * 86_400,
0,
3,
false,
Some(7500),
&["core".into(), "ai_summaries".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig {
is_recurring: true,
renewal_period_days: 30,
grace_period_days: 7,
trial_days: 0,
},
Some(2),
)
.await
.unwrap();
// License on Pro, with a sub. Buyer wants to downgrade to Standard.
let license_id = Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&license_id,
&product.id,
None,
&now_str,
&json!({}),
Some(&pro.id),
Some(&(now + chrono::Duration::days(30)).to_rfc3339()),
0,
3,
&["core".to_string()],
false,
None,
None,
)
.await
.unwrap();
let next_renewal = (now + chrono::Duration::days(20)).to_rfc3339();
sqlx::query(
"INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \
listed_currency, listed_value, status, started_at, next_renewal_at, \
consecutive_failures, created_at, updated_at) \
VALUES('sub2', ?, ?, ?, 30, 'USD', 7500, 'active', ?, ?, 0, ?, ?)",
)
.bind(&license_id)
.bind(&pro.id)
.bind(&product.id)
.bind(&now_str)
.bind(&next_renewal)
.bind(&now_str)
.bind(&now_str)
.execute(&state.db)
.await
.unwrap();
let license = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
let quote = compute_upgrade_quote(&state, &license, &standard, QuoteMode::Buyer).await.unwrap();
assert_eq!(quote.direction, TierDirection::Downgrade);
assert_eq!(quote.proration_charge_value, 0,
"recurring downgrade should be zero-charge today");
// Effective at next renewal — full Pro through current cycle.
match quote.effective_at {
EffectiveAt::At(ref s) => assert_eq!(s, &next_renewal),
EffectiveAt::Immediate => panic!("recurring downgrade should defer to next cycle"),
}
assert_eq!(quote.next_renewal_charge, Some(2500));
}
/// apply_tier_change must update licenses (policy_id +
/// entitlements + max_machines + grace + expires_at) and, if a
/// recurring sub exists, the sub's policy_id + listed_value +
/// period_days.
#[tokio::test]
async fn apply_tier_change_mutates_license_and_subscription() {
let (state, _tmp) = make_state().await;
let now = Utc::now();
let now_str = now.to_rfc3339();
// Build a USD product + Standard/Pro recurring policies + a
// license + sub on Standard (basically the same scaffolding as
// the recurring-upgrade quote test).
let product = repo::create_product(
&state.db,
"apply-test",
"Apply",
"",
2500,
&json!({}),
)
.await
.unwrap();
sqlx::query(
"UPDATE products SET price_currency = 'USD', price_value = 2500 WHERE id = ?",
)
.bind(&product.id)
.execute(&state.db)
.await
.unwrap();
let standard = repo::create_policy(
&state.db,
&product.id,
"Standard",
"standard",
30 * 86_400,
0,
1,
false,
Some(2500),
&["core".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig {
is_recurring: true,
renewal_period_days: 30,
grace_period_days: 7,
trial_days: 0,
},
Some(1),
)
.await
.unwrap();
let pro = repo::create_policy(
&state.db,
&product.id,
"Pro",
"pro",
365 * 86_400, // annual entitlement window
0,
5, // bigger max_machines on Pro
false,
Some(75_000), // $750 / yr, paid annually
&["core".into(), "ai_summaries".into(), "export".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig {
is_recurring: true,
renewal_period_days: 365, // annual cadence
grace_period_days: 14,
trial_days: 0,
},
Some(2),
)
.await
.unwrap();
let license_id = Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&license_id,
&product.id,
None,
&now_str,
&json!({}),
Some(&standard.id),
Some(&(now + chrono::Duration::days(30)).to_rfc3339()),
0,
1,
&["core".to_string()],
false,
None,
None,
)
.await
.unwrap();
let next_renewal = (now + chrono::Duration::days(20)).to_rfc3339();
sqlx::query(
"INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \
listed_currency, listed_value, status, started_at, next_renewal_at, \
consecutive_failures, created_at, updated_at) \
VALUES('sub-apply', ?, ?, ?, 30, 'USD', 2500, 'active', ?, ?, 0, ?, ?)",
)
.bind(&license_id)
.bind(&standard.id)
.bind(&product.id)
.bind(&now_str)
.bind(&next_renewal)
.bind(&now_str)
.bind(&now_str)
.execute(&state.db)
.await
.unwrap();
let product_full = repo::get_product_by_id(&state.db, &product.id)
.await
.unwrap()
.unwrap();
apply_tier_change(&state.db, &license_id, &pro, &product_full)
.await
.expect("apply_tier_change");
// License now reflects Pro's policy_id, entitlements, max_machines.
let license_after = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
assert_eq!(license_after.policy_id.as_deref(), Some(pro.id.as_str()));
assert_eq!(license_after.max_machines, 5);
assert!(license_after.entitlements.contains(&"ai_summaries".to_string()));
assert!(license_after.entitlements.contains(&"export".to_string()));
assert!(license_after.expires_at.is_some(), "annual Pro should set expires_at");
// Subscription now reflects Pro's policy_id, $750 listed_value,
// 365-day period.
let (pol_id, val, period): (String, i64, i64) = sqlx::query_as(
"SELECT policy_id, listed_value, period_days FROM subscriptions WHERE id = 'sub-apply'",
)
.fetch_one(&state.db)
.await
.unwrap();
assert_eq!(pol_id, pro.id);
assert_eq!(val, 75_000);
assert_eq!(period, 365);
}
/// Pending tier_changes with effective_at <= now are applied by
/// the renewal worker before pricing the next cycle. Mirrors the
/// recurring-downgrade flow that ships alongside this hook: admin
/// records "downgrade Pro → Standard at next cycle" with
/// effective_at = next_renewal_at, and the worker fires it on tick.
#[tokio::test]
async fn renewal_worker_applies_pending_tier_change_before_billing() {
use keysat::payment::{
CreateInvoiceParams, CreatedInvoiceHandle, PaymentProvider, ProviderInvoiceStatus,
ProviderKind, ProviderWebhookEvent,
};
use std::any::Any;
use std::sync::atomic::{AtomicU64, Ordering};
// Local mock provider — same shape as the renewal-worker tests'
// mock. Captures the listed_value-derived sat amount so we can
// assert the worker billed AT THE NEW TIER, not the old one.
#[derive(Default)]
struct CapturingProvider {
next_id: AtomicU64,
last_amount_sats: std::sync::atomic::AtomicI64,
}
#[async_trait::async_trait]
impl PaymentProvider for CapturingProvider {
fn kind(&self) -> ProviderKind {
ProviderKind::Btcpay
}
async fn create_invoice(
&self,
params: CreateInvoiceParams<'_>,
) -> anyhow::Result<CreatedInvoiceHandle> {
self.last_amount_sats
.store(params.amount.amount, Ordering::SeqCst);
let n = self.next_id.fetch_add(1, Ordering::SeqCst);
Ok(CreatedInvoiceHandle {
provider_invoice_id: format!("cap-{n}"),
checkout_url: format!("http://cap/{n}"),
})
}
async fn get_invoice_status(&self, _id: &str) -> anyhow::Result<ProviderInvoiceStatus> {
Ok(ProviderInvoiceStatus::Pending)
}
fn validate_webhook(
&self,
_h: &axum::http::HeaderMap,
_b: &[u8],
) -> anyhow::Result<ProviderWebhookEvent> {
anyhow::bail!("not exercised")
}
fn as_any(&self) -> &dyn Any {
self
}
}
let (state, _tmp) = make_state().await;
let mock = Arc::new(CapturingProvider::default());
*state.payment.write().await = Some(mock.clone() as Arc<dyn PaymentProvider>);
let now = Utc::now();
let now_str = now.to_rfc3339();
// SAT-priced product (no rate fetcher) for a clean assertion on
// the amount billed.
let product = repo::create_product(
&state.db,
"rw-pending",
"Renewal worker pending",
"",
2500, // 2500 sats base
&json!({}),
)
.await
.unwrap();
let standard = repo::create_policy(
&state.db,
&product.id,
"Standard",
"standard",
30 * 86_400,
0,
1,
false,
Some(2500), // 2500 sats / mo
&["core".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig {
is_recurring: true,
renewal_period_days: 30,
grace_period_days: 7,
trial_days: 0,
},
Some(1),
)
.await
.unwrap();
let pro = repo::create_policy(
&state.db,
&product.id,
"Pro",
"pro",
30 * 86_400,
0,
3,
false,
Some(7500), // 7500 sats / mo
&["core".into(), "ai_summaries".into()],
&json!({}),
None,
0,
None,
repo::RecurringConfig {
is_recurring: true,
renewal_period_days: 30,
grace_period_days: 7,
trial_days: 0,
},
Some(2),
)
.await
.unwrap();
// License + sub on Pro, due now (next_renewal_at in the past).
let license_id = Uuid::new_v4().to_string();
repo::create_license(
&state.db,
&license_id,
&product.id,
None,
&now_str,
&json!({}),
Some(&pro.id),
Some(&(now + chrono::Duration::days(30)).to_rfc3339()),
0,
3,
&["core".to_string(), "ai_summaries".to_string()],
false,
None,
None,
)
.await
.unwrap();
let past_due = (now - chrono::Duration::minutes(5)).to_rfc3339();
sqlx::query(
"INSERT INTO subscriptions(id, license_id, policy_id, product_id, period_days, \
listed_currency, listed_value, status, started_at, next_renewal_at, \
consecutive_failures, created_at, updated_at) \
VALUES('sub-rw-pending', ?, ?, ?, 30, 'SAT', 7500, 'active', ?, ?, 0, ?, ?)",
)
.bind(&license_id)
.bind(&pro.id)
.bind(&product.id)
.bind(&now_str)
.bind(&past_due)
.bind(&now_str)
.bind(&now_str)
.execute(&state.db)
.await
.unwrap();
// Operator (or the future admin endpoint) records a downgrade
// tier_change with effective_at = now (= already past). No
// invoice attached (this is the comp / scheduled-downgrade
// shape).
record_tier_change(
&state.db,
&license_id,
&pro.id,
&standard.id,
TierDirection::Downgrade,
"SAT",
0,
None,
&now_str,
"admin",
Some("scheduled downgrade for cycle boundary"),
)
.await
.unwrap();
// Tick the renewal worker.
keysat::subscriptions::tick(&state).await.unwrap();
// The new invoice was created at the NEW tier's price (2500
// sats), not the old one (7500 sats). This proves the renewal
// worker applied the pending tier change BEFORE pricing.
let billed = mock.last_amount_sats.load(Ordering::SeqCst);
assert_eq!(
billed, 2500,
"renewal must bill at the new (Standard) tier after the pending downgrade applied; got {billed} sats"
);
// License is now on Standard (apply_tier_change ran during the hook).
let license_after = repo::get_license_by_id(&state.db, &license_id)
.await
.unwrap()
.unwrap();
assert_eq!(license_after.policy_id.as_deref(), Some(standard.id.as_str()));
}
/// record_tier_change writes the audit row, and
/// list_tier_changes_for_license / get_tier_change_by_invoice
/// surface it back. Round-trips the data we'd write at settle time.
#[tokio::test]
async fn record_and_lookup_tier_change_round_trip() {
let (state, _tmp) = make_state().await;
let (license_id, standard_id, pro_id) = seed_perpetual_ladder(&state).await;
// Seed a placeholder invoice so the FK on tier_changes.invoice_id
// can succeed.
let invoice_id = Uuid::new_v4().to_string();
sqlx::query(
"INSERT INTO invoices(id, btcpay_invoice_id, product_id, amount_sats, \
checkout_url, status, created_at, updated_at, listed_currency, \
listed_value, policy_id) \
VALUES(?, ?, (SELECT product_id FROM licenses WHERE id = ?), 0, \
?, 'pending', ?, ?, 'USD', 5000, ?)",
)
.bind(&invoice_id)
.bind(format!("test-inv-{}", &invoice_id[..8]))
.bind(&license_id)
.bind("http://test.invalid/inv")
.bind(Utc::now().to_rfc3339())
.bind(Utc::now().to_rfc3339())
.bind(&pro_id)
.execute(&state.db)
.await
.unwrap();
let id = record_tier_change(
&state.db,
&license_id,
&standard_id,
&pro_id,
TierDirection::Upgrade,
"USD",
5000,
Some(&invoice_id),
&Utc::now().to_rfc3339(),
"buyer",
Some("user clicked upgrade in app"),
)
.await
.expect("record_tier_change");
// list_for_license returns the row.
let history = list_tier_changes_for_license(&state.db, &license_id)
.await
.unwrap();
assert_eq!(history.len(), 1);
assert_eq!(history[0].id, id);
assert_eq!(history[0].direction, "upgrade");
assert_eq!(history[0].proration_charge_value, 5000);
assert_eq!(history[0].listed_currency, "USD");
assert_eq!(history[0].invoice_id.as_deref(), Some(invoice_id.as_str()));
// get_by_invoice round-trips too.
let by_inv = keysat::upgrades::get_tier_change_by_invoice(&state.db, &invoice_id)
.await
.unwrap()
.expect("found by invoice");
assert_eq!(by_inv.id, id);
}