Tier upgrades Phase 2 — quote logic + apply step
Builds on 8ce78ab (Phase 1 schema). Pure module work — no HTTP
endpoints yet (those are Phase 3). Operator-invisible until Phase
3-5 wire up the buyer / admin / UI surfaces.
src/upgrades.rs:
- UpgradeQuote / TierDirection / EffectiveAt structs (serde-ready
for the future endpoint).
- compute_upgrade_quote(state, license, target_policy) — the
buyer-facing quote function. Enforces ladder rules:
* both policies must have non-NULL tier_rank
* sideways (same-rank) changes rejected — admin-only
* cross-product target rejected
* inactive target rejected
* same-policy noop rejected
* perpetual downgrades rejected (refund decision = admin-only)
* recurring → perpetual downgrade rejected (admin-only)
- Branches on perpetual vs recurring:
* Perpetual upgrade: flat (target - current) listed price diff,
effective_at = Immediate.
* Recurring upgrade: prorated (target - current) × days_remaining
/ period_days; effective_at = Immediate; surfaces
next_renewal_charge for the buyer to see what they'll pay
going forward.
* Recurring downgrade: zero-charge today, effective_at =
next_renewal_at (full current cycle at old price).
* Free → recurring: full first-cycle price (no proration since
"remaining value" of free is 0).
- record_tier_change — INSERT helper for the audit row.
- apply_tier_change — UPDATE helper that mutates the license row
(policy_id, entitlements_json, expires_at, max_machines,
grace_seconds, is_trial) and any tied subscription
(policy_id, listed_value, period_days). Recurring → perpetual
apply also cancels the now-orphaned subscription so the renewal
worker stops touching it.
- get_tier_change / list_tier_changes_for_license /
get_tier_change_by_invoice — read helpers (Phase 3 webhook
handler will use the by_invoice variant).
tier_rank threading:
- models::Policy gains `tier_rank: Option<i64>`.
- POLICY_COLS + row_to_policy include tier_rank with try_get
Option<i64> + flatten so NULL stays NULL (a valid state) and
pre-0013 databases also resolve to None.
- repo::create_policy gets a `tier_rank: Option<i64>` param.
- repo::RecurringUpdate gains `tier_rank: Option<Option<i64>>`
for nullable-patch semantics matching price_sats_override.
- CreatePolicyReq + UpdatePolicyReq accept tier_rank with the
same shape; range-validated 0..=1000.
tests/upgrades.rs (8 new tests):
- perpetual_upgrade_quote_returns_flat_price_difference
- perpetual_downgrade_is_admin_only (rejection w/ helpful msg)
- quote_rejects_target_with_null_tier_rank
- quote_rejects_same_policy
- recurring_upgrade_prorates_against_time_remaining (asserts
~half-of-diff for ~half-of-cycle remaining; tolerance window)
- recurring_downgrade_is_zero_charge_at_next_cycle (verifies
effective_at lands on next_renewal_at)
- apply_tier_change_mutates_license_and_subscription (Standard
monthly → Pro annual changes max_machines, entitlements,
expires_at, sub policy_id + listed_value + period_days)
- record_and_lookup_tier_change_round_trip
Test count: 66 (was 58; +8).
This commit is contained in:
@@ -2028,6 +2028,7 @@ async fn edit_policy_to_recurring_respects_tier_gate() {
|
||||
0,
|
||||
None,
|
||||
repo::RecurringConfig::off(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create_policy");
|
||||
@@ -2137,6 +2138,7 @@ async fn seed_subscription(state: &AppState) -> (String, String, String) {
|
||||
grace_period_days: 7,
|
||||
trial_days: 0,
|
||||
},
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("create_policy");
|
||||
|
||||
@@ -0,0 +1,790 @@
|
||||
//! 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, 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).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)
|
||||
.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)
|
||||
.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)
|
||||
.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).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("e.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).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);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user