Files
keysat/licensing-service/src/api/upgrade.rs
T
Grant 7c4dfbacd2 WIP — port purchase/subscriptions/reconcile/upgrade/tipping to merchant-profile resolution (part 2)
Threads the merchant-profile + payment-provider snapshot semantics through
every call site that used to call state.payment_provider() (the legacy
"active provider" singleton). New invoices now record which provider
settled them; subscriptions snapshot both merchant_profile_id and
payment_provider_id at creation so mid-cycle product re-routing doesn't
redirect existing buyers; the reconciler picks the right provider per
invoice; tipping draws from the same Bitcoin balance that received the
purchase; tier-change invoices stick with the buyer's existing merchant
identity.

migrations/0021_invoice_provider_link.sql (new)
  Adds invoices.payment_provider_id (nullable FK), backfills existing
  pending/settled rows to the earliest-connected provider on the default
  profile. Additive — no drops, no removals. Companion to 0020 from the
  foundation commit.

models.rs
  Invoice gains payment_provider_id: Option<String>.

db/repo.rs
  row_to_invoice reads the new column. All three invoice SELECTs include
  it. create_invoice + create_invoice_with_currency take a new optional
  payment_provider_id parameter and persist it on INSERT.

subscriptions.rs
  Subscription struct gains merchant_profile_id + payment_provider_id
  (snapshotted on create). SUB_COLS + row_to_subscription + the manual
  SELECT in find_lapsing_subscriptions all updated. create_subscription
  accepts both new fields and writes them on the INSERT row.

  renew_one — reads the sub's payment_provider_id snapshot and resolves
  the provider via state.payment_provider_by_id(). Falls back to the
  legacy state.payment_provider() for any subs created pre-:52 that
  the migration backfill missed.

  capture_zaprite_payment_profile — uses the INVOICE's provider, not
  "the active one." Saved-profile ids are scoped per Zaprite org; using
  the wrong provider would fail the lookup.

  try_auto_charge_zaprite — uses the sub's snapshotted provider (same
  rationale).

reconcile.rs
  Per-invoice provider lookup. Each pending invoice is reconciled
  against state.payment_provider_by_id(inv.payment_provider_id), with
  graceful fallback for NULL provider ids. No more single-global-
  provider assumption.

tipping.rs
  Tip pay-out uses the provider that settled the license's purchase
  invoice (joined via licenses.invoice_id). Same rationale as the
  capture hook — the tip needs to draw from the right LN node.

api/upgrade.rs (both buyer-driven and admin-driven tier-change sites)
  Tier-change invoices ride on existing licenses. The right provider
  is whichever the license's subscription is snapshotted to (so the
  proration charge settles to the same merchant identity that collects
  renewal fees). Falls back to the invoice's recorded provider, then
  the legacy default, for licenses with no subscription or pre-
  snapshot rows.

api/purchase.rs
  StartPurchaseReq gains an optional `rail` field
  ("lightning"/"onchain"/"card") for the future buy-page multi-rail
  picker. When omitted (today's behavior), the daemon picks the first
  rail the product's merchant profile exposes — which is correct for
  single-provider operators AND back-compat for any pre-:52 client
  not yet sending the field.

  Provider resolution: product → merchant_profile → rail →
  resolve_provider_for_profile_rail. The redirect_url defaults to the
  profile's post_purchase_redirect_url (with {invoice_id} substitution)
  if set, else Keysat's own /thank-you. New invoices carry their
  provider's id via the new create_invoice_with_currency parameter.

api/webhook.rs
  issue_license_for_invoice now passes snapshot fields when calling
  subscriptions::create_subscription — both merchant_profile_id (from
  product lookup) and payment_provider_id (from the invoice row).

main.rs
  Replaces the legacy "active provider preference" boot loader with a
  default-profile-first-provider warm-up. The legacy state.payment
  singleton stays populated for back-compat with call sites that
  haven't yet migrated to the on-demand resolution path. Pre-migration
  fallback to the old singleton-config loaders preserved so the
  daemon still boots cleanly on a DB that hasn't run 0020 yet.

Remaining for part 3:
  - BTCPay + Zaprite connect flows take merchant_profile_id and
    INSERT into payment_providers (currently still write to the
    dropped singleton tables, broken post-migration).
  - api/payment_provider.rs activate endpoint becomes irrelevant in
    the new model — repurpose as list-providers, or delete.
  - Thank-you page (api/mod.rs) provider-kind lookup ports to the
    invoice's recorded provider.
  - Webhook routes refactor to /v1/{kind}/webhook/{provider_id}.
  - Admin UI for Merchant Profiles + product picker + buy-page brand
    block + rail picker.
  - Tier-cap wire-up for unlimited_merchant_profiles entitlement.
  - Version bump to :52 + release notes.

Build: cargo check passes. Deprecation warnings remaining flag exactly
the call sites listed above as the part 3 todo list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-03 22:26:22 -05:00

648 lines
24 KiB
Rust

//! Buyer-facing tier upgrade endpoints.
//!
//! Phase 3 of TIER_UPGRADES_DESIGN.md. Two endpoints:
//!
//! - `POST /v1/upgrade-quote` — read-only quote: "what would I owe
//! if I upgraded to <tier>?"
//! - `POST /v1/upgrade` — start an upgrade: creates a payment
//! invoice for the prorated charge,
//! returns the checkout URL. Webhook
//! handler applies the change on settle.
//!
//! Auth model matches the recovery + buyer-cancel endpoints — the
//! buyer's signed license key in the request body is the credential.
//! The daemon verifies the signature, looks up the local license row,
//! computes the quote, optionally creates an invoice. No admin token,
//! no cookie.
//!
//! Out of scope for Phase 3 (Phase 4 with admin endpoint):
//! - **Buyer-initiated recurring downgrades.** The quote function in
//! `crate::upgrades` already returns a 0-charge quote with
//! `effective_at = next_renewal_at`, but actually applying the
//! change at the right moment (cycle boundary) requires renewal-
//! worker integration. Phase 4 lands that. For now this endpoint
//! rejects buyer downgrades with a 403 and a hint to contact support.
//! - **Admin force-change.** `POST /v1/admin/licenses/:id/change-tier`
//! ships in Phase 4.
use crate::api::admin::{request_context, require_admin};
use crate::api::AppState;
use crate::error::{AppError, AppResult};
use crate::payment::{CreateInvoiceParams, Money};
use axum::{
extract::{Path, State},
http::HeaderMap,
Json,
};
use serde::Deserialize;
use serde_json::{json, Value};
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct QuoteReq {
/// Buyer's signed license key. Verified before we compute anything.
pub license_key: String,
/// Slug of the policy the buyer wants to move to. Resolved within
/// the license's product (cross-product changes are not supported
/// by the quote function).
pub target_policy_slug: String,
}
/// `POST /v1/upgrade-quote` — quote-only. No DB writes, no invoice.
/// Returns the same shape `crate::upgrades::UpgradeQuote` produces,
/// flattened to JSON for SDK consumption.
pub async fn quote(
State(state): State<AppState>,
Json(body): Json<QuoteReq>,
) -> AppResult<Json<Value>> {
let (license, target_policy) = resolve_request(&state, &body.license_key, &body.target_policy_slug).await?;
let q = crate::upgrades::compute_upgrade_quote(&state, &license, &target_policy, crate::upgrades::QuoteMode::Buyer).await?;
Ok(Json(quote_to_json(&q)))
}
#[derive(Debug, Deserialize)]
pub struct StartReq {
pub license_key: String,
pub target_policy_slug: String,
/// Optional buyer-supplied redirect target on payment-provider
/// success. Mirrors the purchase flow's same-named field.
#[serde(default)]
pub redirect_url: Option<String>,
}
/// `POST /v1/upgrade` — buyer commits to the upgrade. We:
/// 1. Recompute the quote (DON'T trust client-side shaping; the
/// on-chain charge must match what the daemon's logic decides).
/// 2. Reject buyer-initiated downgrades for v0.2.x (Phase 4 ships).
/// 3. Reject zero-charge upgrades — those are admin-only (e.g., free
/// upgrades come through the comp path, not the buyer path).
/// 4. Create a provider invoice for the prorated charge.
/// 5. Persist the local invoice + tier_changes row tying them
/// together. The webhook handler picks it up on settle.
///
/// Returns `{ invoice_id, checkout_url, amount_sats }` so the SDK can
/// open the checkout URL in the buyer's browser, then poll the
/// existing `/v1/purchase/:invoice_id` to detect settle (the webhook
/// applies the change server-side).
pub async fn start(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<StartReq>,
) -> AppResult<Json<Value>> {
let (ip, ua) = request_context(&headers);
let (license, target_policy) =
resolve_request(&state, &body.license_key, &body.target_policy_slug).await?;
let quote = crate::upgrades::compute_upgrade_quote(&state, &license, &target_policy, crate::upgrades::QuoteMode::Buyer).await?;
// Phase 3 scope: buyer endpoint handles UPGRADE only. Downgrades
// (even 0-charge ones) need the cycle-boundary apply path which
// ships with Phase 4 admin endpoint + renewal-worker integration.
if quote.direction == crate::upgrades::TierDirection::Downgrade {
return Err(AppError::Forbidden);
}
if quote.proration_charge_value <= 0 {
return Err(AppError::BadRequest(
"this upgrade has no charge owed; admin must apply it as a comp via \
POST /v1/admin/licenses/:id/change-tier (Phase 4)".into(),
));
}
// Convert proration to sats. SAT-currency licenses skip the rate
// fetcher (identity). Fiat licenses re-quote against the live rate.
let conversion = crate::rates::convert_to_sats(
&state,
&quote.listed_currency,
quote.proration_charge_value,
)
.await
.map_err(|e| AppError::Upstream(format!("rate conversion failed: {e:#}")))?;
let amount_sats = conversion.sats.max(1);
// Create provider invoice. Same trait method the purchase + renewal
// paths use, so any provider-specific concerns (URL rewriting,
// metadata enrichment) live inside the impl.
//
// Tier-change invoices ride on an existing license. The right provider
// is whichever one the license's existing subscription is snapshotted
// to — so the proration charge settles to the same merchant identity
// that's been collecting renewal fees. Falls back to the license's
// first-cycle invoice provider, then the legacy default, for licenses
// with no subscription (one-shot upgrades) or pre-snapshot rows.
let snapshot_provider_id = crate::subscriptions::get_subscription_by_license_id(
&state.db, &license.id,
)
.await
.ok()
.flatten()
.and_then(|s| s.payment_provider_id);
let provider_id_for_upgrade = match snapshot_provider_id {
Some(p) => Some(p),
None => {
sqlx::query_scalar::<_, Option<String>>(
"SELECT i.payment_provider_id FROM invoices i \
JOIN licenses l ON l.invoice_id = i.id \
WHERE l.id = ?",
)
.bind(&license.id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.flatten()
}
};
let provider = match provider_id_for_upgrade.as_deref() {
Some(pid) => state.payment_provider_by_id(pid).await?,
None => state.payment_provider().await?,
};
let internal_invoice_id = Uuid::new_v4().to_string();
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
state.config.public_base_url, internal_invoice_id
);
let redirect_url = body
.redirect_url
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or(&default_redirect);
let created = provider
.create_invoice(CreateInvoiceParams {
amount: Money::sats(amount_sats),
redirect_url,
metadata: json!({
"productId": target_policy.product_id,
"intent": "tier_change",
"licenseId": license.id,
"fromPolicyId": quote.from_policy_id,
"toPolicyId": quote.to_policy_id,
}),
external_order_id: &internal_invoice_id,
buyer_email: license.buyer_email.as_deref(),
// Tier-change invoices ride on an existing license; if
// the underlying subscription already captured a saved
// payment profile on its first cycle, we keep using it
// for future renewals. No need to re-prompt for
// save-card here.
allow_save_payment_profile: None,
})
.await
.map_err(|e| AppError::Upstream(format!("provider create_invoice: {e:#}")))?;
// Persist invoice. The exchange rate fields capture the conversion
// so the receipt UI can show "you paid X sats which is $Y at the
// moment of charge." Same shape as the regular purchase path.
let invoice = crate::db::repo::create_invoice_with_currency(
&state.db,
&internal_invoice_id,
&created.provider_invoice_id,
&target_policy.product_id,
amount_sats,
&created.checkout_url,
license.buyer_email.as_deref(),
Some("tier upgrade"),
Some(&quote.to_policy_id),
Some(&quote.listed_currency),
Some(quote.proration_charge_value),
conversion.rate_centibps,
Some(conversion.source.as_str()),
provider_id_for_upgrade.as_deref(),
)
.await?;
// Record the tier_change row, tied to this invoice. The webhook
// handler looks it up by invoice_id on settle and applies.
let effective_at = match &quote.effective_at {
crate::upgrades::EffectiveAt::Immediate => chrono::Utc::now().to_rfc3339(),
crate::upgrades::EffectiveAt::At(s) => s.clone(),
};
let tier_change_id = crate::upgrades::record_tier_change(
&state.db,
&license.id,
&quote.from_policy_id,
&quote.to_policy_id,
quote.direction,
&quote.listed_currency,
quote.proration_charge_value,
Some(&invoice.id),
&effective_at,
"buyer",
None,
)
.await
.map_err(AppError::Internal)?;
// Audit row in the generic stream; tier_changes is its own
// audit-shaped table, but audit_log is the single "what
// happened" feed operators read from.
let _ = crate::db::repo::insert_audit(
&state.db,
"buyer_license_key",
Some(&license.id),
"subscription.upgrade.started",
Some("tier_change"),
Some(&tier_change_id),
ip.as_deref(),
ua.as_deref(),
&json!({
"license_id": license.id,
"from_policy_id": quote.from_policy_id,
"to_policy_id": quote.to_policy_id,
"invoice_id": invoice.id,
"listed_currency": quote.listed_currency,
"proration_charge_value": quote.proration_charge_value,
"amount_sats": amount_sats,
}),
)
.await;
Ok(Json(json!({
"invoice_id": invoice.id,
"provider_invoice_id": created.provider_invoice_id,
"checkout_url": created.checkout_url,
"amount_sats": amount_sats,
"proration_charge_value": quote.proration_charge_value,
"listed_currency": quote.listed_currency,
"tier_change_id": tier_change_id,
"from_policy_slug": quote.from_policy_slug,
"to_policy_slug": quote.to_policy_slug,
})))
}
/// Verify the buyer's license key, look up the local license row, and
/// resolve the target policy by slug (under the license's product).
/// Centralises the auth + lookup so quote and start handlers can
/// stay narrow. 401 on auth failure (don't leak whether the policy
/// exists), 404 on missing target.
async fn resolve_request(
state: &AppState,
license_key: &str,
target_policy_slug: &str,
) -> AppResult<(crate::models::License, crate::models::Policy)> {
let (payload, signature, signed_bytes) =
crate::crypto::parse_key(license_key).map_err(|_| AppError::Unauthorized)?;
crate::crypto::verify_payload(&state.keypair.verifying, &signed_bytes, &signature)
.map_err(|_| AppError::Unauthorized)?;
let license_id = payload.license_id.to_string();
let license = crate::db::repo::get_license_by_id(&state.db, &license_id)
.await?
.ok_or(AppError::Unauthorized)?;
if license.revoked_at.is_some() || license.suspended_at.is_some() {
return Err(AppError::Unauthorized);
}
let target_policy = crate::db::repo::get_policy_by_slug(
&state.db,
&license.product_id,
target_policy_slug,
)
.await?
.ok_or_else(|| AppError::NotFound(format!("target policy '{target_policy_slug}'")))?;
Ok((license, target_policy))
}
// ---------------------------------------------------------------------
// Admin force-change endpoint (Phase 4)
// ---------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct AdminChangeReq {
/// Slug of the policy to move the license to. Resolved within
/// the license's product.
pub to_policy_slug: String,
/// When true, apply the change immediately with no invoice
/// (operator absorbs the cost — comp upgrade, support fix-up,
/// fixing a misissue). When false, behave like the buyer
/// endpoint: create an invoice for the prorated charge,
/// webhook applies on settle.
#[serde(default)]
pub skip_payment: bool,
/// Free-form audit note. Surfaced in audit_log + tier_changes.reason.
#[serde(default)]
pub reason: Option<String>,
}
/// `POST /v1/admin/licenses/:id/change-tier` — admin force-change.
/// Bypasses ladder rules (sideways changes, NULL-rank policies,
/// perpetual downgrades all allowed). Two modes:
///
/// - `skip_payment: true`: applies immediately. tier_changes row
/// is written with invoice_id = NULL and proration_charge_value = 0.
/// The license's policy_id + entitlements + expiry + max_machines
/// flip on the spot; any tied subscription's policy_id +
/// listed_value + period_days update so the next renewal bills the
/// new tier.
///
/// - `skip_payment: false`: same flow as the buyer's `/v1/upgrade` —
/// creates a provider invoice for the prorated charge, persists
/// the local invoice + a tier_changes row tied to it. The webhook
/// handler applies on settle. The operator gets the checkout URL
/// back and forwards it to the buyer through whatever channel
/// they prefer (email, chat, etc.).
pub async fn admin_change(
State(state): State<AppState>,
headers: HeaderMap,
Path(license_id): Path<String>,
Json(body): Json<AdminChangeReq>,
) -> AppResult<Json<Value>> {
let actor_hash = require_admin(&state, &headers)?;
let (ip, ua) = request_context(&headers);
let reason = body.reason.as_deref().filter(|s| !s.trim().is_empty());
// Refuse to change-tier on the daemon's OWN self-license. The
// signed key on disk is the immutable proof-of-tier and won't
// reflect any DB change anyway — operators trying to "downgrade
// themselves to test gating" hit a recursion that produces a
// confusing half-applied state. Live-refresh from the DB does
// pick up the new entitlements, but the operator should drive
// self-tier changes through the proper re-mint flow on the
// master Keysat instead. For now, refuse with a clear message.
{
let current = state.self_tier.read().await.clone();
if let crate::license_self::Tier::Licensed { license_id: self_id, .. } = current {
if self_id.to_string() == license_id {
return Err(AppError::BadRequest(
"cannot change tier on the daemon's own self-license — \
re-issue a new key from the master Keysat and activate it via \
'Activate Keysat license' instead. Or, for testing Creator-tier \
gates, temporarily move /data/keysat-license.txt aside and \
restart Keysat to boot Unlicensed."
.into(),
));
}
}
}
let license = crate::db::repo::get_license_by_id(&state.db, &license_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("license '{license_id}'")))?;
let target_policy = crate::db::repo::get_policy_by_slug(
&state.db,
&license.product_id,
&body.to_policy_slug,
)
.await?
.ok_or_else(|| AppError::NotFound(format!("target policy '{}'", body.to_policy_slug)))?;
let quote = crate::upgrades::compute_upgrade_quote(
&state,
&license,
&target_policy,
crate::upgrades::QuoteMode::Admin,
)
.await?;
if body.skip_payment {
// Comp path: apply immediately, no invoice.
let product = crate::db::repo::get_product_by_id(&state.db, &target_policy.product_id)
.await?
.ok_or_else(|| AppError::NotFound(format!("product '{}'", target_policy.product_id)))?;
crate::upgrades::apply_tier_change(&state.db, &license.id, &target_policy, &product)
.await
.map_err(AppError::Internal)?;
let tier_change_id = crate::upgrades::record_tier_change(
&state.db,
&license.id,
&quote.from_policy_id,
&quote.to_policy_id,
quote.direction,
&quote.listed_currency,
0, // comp: no charge
None,
&chrono::Utc::now().to_rfc3339(),
"admin",
reason,
)
.await
.map_err(AppError::Internal)?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.change_tier.comp",
Some("tier_change"),
Some(&tier_change_id),
ip.as_deref(),
ua.as_deref(),
&json!({
"license_id": license.id,
"from_policy_id": quote.from_policy_id,
"to_policy_id": quote.to_policy_id,
"to_policy_slug": target_policy.slug,
"direction": quote.direction.as_str(),
"reason": reason,
"skip_payment": true,
}),
)
.await;
crate::webhooks::dispatch(
&state,
"license.tier_changed",
&json!({
"license_id": license.id,
"product_id": product.id,
"from_policy_id": quote.from_policy_id,
"to_policy_id": quote.to_policy_id,
"to_policy_slug": target_policy.slug,
"direction": quote.direction.as_str(),
"actor": "admin",
"tier_change_id": tier_change_id,
}),
)
.await;
return Ok(Json(json!({
"ok": true,
"applied": true,
"license_id": license.id,
"tier_change_id": tier_change_id,
"skip_payment": true,
"from_policy_slug": quote.from_policy_slug,
"to_policy_slug": quote.to_policy_slug,
})));
}
// Paid path: create invoice + tier_changes row tied to it.
// If the quote came back with proration <= 0 (sideways or
// operator forcing a same-price change), there's nothing to bill.
// Surface a clear error so the operator switches to skip_payment=true.
if quote.proration_charge_value <= 0 {
return Err(AppError::BadRequest(
"this change has no charge owed; use skip_payment=true to apply as a comp"
.into(),
));
}
let conversion = crate::rates::convert_to_sats(
&state,
&quote.listed_currency,
quote.proration_charge_value,
)
.await
.map_err(|e| AppError::Upstream(format!("rate conversion failed: {e:#}")))?;
let amount_sats = conversion.sats.max(1);
// Same provider-resolution pattern as the buyer-driven tier-change
// above: prefer the license's snapshotted subscription provider so
// the admin charge settles to the same merchant identity.
let snapshot_provider_id = crate::subscriptions::get_subscription_by_license_id(
&state.db, &license.id,
)
.await
.ok()
.flatten()
.and_then(|s| s.payment_provider_id);
let provider_id_for_upgrade = match snapshot_provider_id {
Some(p) => Some(p),
None => {
sqlx::query_scalar::<_, Option<String>>(
"SELECT i.payment_provider_id FROM invoices i \
JOIN licenses l ON l.invoice_id = i.id \
WHERE l.id = ?",
)
.bind(&license.id)
.fetch_optional(&state.db)
.await
.ok()
.flatten()
.flatten()
}
};
let provider = match provider_id_for_upgrade.as_deref() {
Some(pid) => state.payment_provider_by_id(pid).await?,
None => state.payment_provider().await?,
};
let internal_invoice_id = Uuid::new_v4().to_string();
let default_redirect = format!(
"{}/thank-you?invoice_id={}",
state.config.public_base_url, internal_invoice_id
);
let created = provider
.create_invoice(CreateInvoiceParams {
amount: Money::sats(amount_sats),
redirect_url: &default_redirect,
metadata: json!({
"productId": target_policy.product_id,
"intent": "admin_tier_change",
"licenseId": license.id,
"fromPolicyId": quote.from_policy_id,
"toPolicyId": quote.to_policy_id,
}),
external_order_id: &internal_invoice_id,
buyer_email: license.buyer_email.as_deref(),
// Admin-driven tier change — same as the buyer-driven
// tier-change path above: existing subscription keeps
// its saved profile (if any), so no re-prompt.
allow_save_payment_profile: None,
})
.await
.map_err(|e| AppError::Upstream(format!("provider create_invoice: {e:#}")))?;
let invoice = crate::db::repo::create_invoice_with_currency(
&state.db,
&internal_invoice_id,
&created.provider_invoice_id,
&target_policy.product_id,
amount_sats,
&created.checkout_url,
license.buyer_email.as_deref(),
Some("admin tier-change"),
Some(&quote.to_policy_id),
Some(&quote.listed_currency),
Some(quote.proration_charge_value),
conversion.rate_centibps,
Some(conversion.source.as_str()),
provider_id_for_upgrade.as_deref(),
)
.await?;
let effective_at = match &quote.effective_at {
crate::upgrades::EffectiveAt::Immediate => chrono::Utc::now().to_rfc3339(),
crate::upgrades::EffectiveAt::At(s) => s.clone(),
};
let tier_change_id = crate::upgrades::record_tier_change(
&state.db,
&license.id,
&quote.from_policy_id,
&quote.to_policy_id,
quote.direction,
&quote.listed_currency,
quote.proration_charge_value,
Some(&invoice.id),
&effective_at,
"admin",
reason,
)
.await
.map_err(AppError::Internal)?;
let _ = crate::db::repo::insert_audit(
&state.db,
"admin_api_key",
Some(&actor_hash),
"license.change_tier.invoice_created",
Some("tier_change"),
Some(&tier_change_id),
ip.as_deref(),
ua.as_deref(),
&json!({
"license_id": license.id,
"from_policy_id": quote.from_policy_id,
"to_policy_id": quote.to_policy_id,
"to_policy_slug": target_policy.slug,
"direction": quote.direction.as_str(),
"invoice_id": invoice.id,
"amount_sats": amount_sats,
"listed_currency": quote.listed_currency,
"proration_charge_value": quote.proration_charge_value,
"reason": reason,
"skip_payment": false,
}),
)
.await;
Ok(Json(json!({
"ok": true,
"applied": false,
"license_id": license.id,
"tier_change_id": tier_change_id,
"invoice_id": invoice.id,
"provider_invoice_id": created.provider_invoice_id,
"checkout_url": created.checkout_url,
"amount_sats": amount_sats,
"proration_charge_value": quote.proration_charge_value,
"listed_currency": quote.listed_currency,
"from_policy_slug": quote.from_policy_slug,
"to_policy_slug": quote.to_policy_slug,
"skip_payment": false,
})))
}
fn quote_to_json(q: &crate::upgrades::UpgradeQuote) -> Value {
let effective_at = match &q.effective_at {
crate::upgrades::EffectiveAt::Immediate => json!("immediate"),
crate::upgrades::EffectiveAt::At(s) => json!(s),
};
json!({
"from_policy_id": q.from_policy_id,
"from_policy_slug": q.from_policy_slug,
"to_policy_id": q.to_policy_id,
"to_policy_slug": q.to_policy_slug,
"direction": q.direction.as_str(),
"listed_currency": q.listed_currency,
"proration_charge_value": q.proration_charge_value,
"effective_at": effective_at,
"next_renewal_charge": q.next_renewal_charge,
"next_renewal_period_days": q.next_renewal_period_days,
})
}