Files
keysat/licensing-service/src/models.rs
T
Grant c301eacfaa Recurring subs Phase 4 — admin UI + buy-page rendering + Pro-tier gate
Phase 4 surfaces the recurring-subscription schema (migration 0011) and
renewal-worker (Phase 2, commit 7007bf8) through every layer operators
and buyers actually see:

API
- Policy struct + repo gain is_recurring, renewal_period_days,
  grace_period_days, trial_days. RecurringConfig / RecurringUpdate
  helper structs keep create_policy / update_policy signatures
  manageable.
- CreatePolicyReq + UpdatePolicyReq accept all four fields. Validation
  rejects internally inconsistent combos (recurring=true with period=0,
  trial > renewal period, period >5y, grace >90d).
- New tier::enforce_recurring_feature gate. Pro/Patron only — Creator
  and Unlicensed get a 402 with upgrade_url. The gate fires on both
  create-policy and the false→true transition in update-policy.
- list_public_policies now surfaces is_recurring, renewal_period_days,
  trial_days so SDKs and the buy page can render cadence.

Admin UI (web/index.html)
- Create-policy form gets a "Recurring subscription (Pro)" section:
  is_recurring checkbox + cadence preset (monthly/quarterly/etc/custom)
  + grace period + trial days. Live enable/disable: the inputs gray
  out unless the box is ticked, and the custom-days input grays out
  unless "Custom" is selected.
- Edit-policy modal mirrors the same section, pre-populated from the
  policy's current values.
- Policies-list table shows a gold "every Nd" badge alongside the
  trial badge so operators can see at a glance which policies renew.

Buy page (/buy/<slug>)
- Tier cards on a recurring policy render a "Renews monthly/annually/
  every N days" meta line + a "/mo" / "/yr" / "/Nd" suffix on the
  price unit, so the headline reads "$25 / mo" not just "$25".
- First-cycle trial banner shows when trial_days > 0.
- TIERS JSON map exposes is_recurring + renewal_period_days +
  trial_days so the JS price-update path keeps the cadence suffix
  in sync when the buyer clicks between tiers.

Tests (+4, total now 53)
- recurring_policy_blocked_on_creator_tier — 402 + upgrade_url
- pro_tier_creates_monthly_recurring_policy — full create + verify
  via both admin GET and public list endpoint
- recurring_requires_positive_period — validator rejects period=0
- edit_policy_to_recurring_respects_tier_gate — Creator 402 on flip,
  Pro 200 on same flip, name-only PATCH on already-recurring policy
  doesn't re-fire the gate after downgrade

Drive-by: wrap the state-machine ASCII diagram in subscriptions.rs in
a ```text fence so cargo's doc-test runner stops trying to compile box
characters as Rust tokens.
2026-05-08 17:47:55 -05:00

300 lines
9.8 KiB
Rust

//! Domain models — shared types used by DB, API, and BTCPay layers.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Product {
pub id: String,
pub slug: String,
pub name: String,
pub description: String,
/// Sat-denominated price. For SAT-currency products this equals
/// `price_value`. For fiat-priced products (USD, EUR, etc.) this
/// is a snapshot from the most recent invoice creation against
/// the product, kept for back-compat with v0.1 SDKs and admin UI
/// that haven't migrated to the typed currency view yet. The
/// canonical price is `price_currency` + `price_value`.
pub price_sats: i64,
/// Operator-facing currency: 'SAT', 'BTC', 'USD', 'EUR' (and
/// future ISO 4217 codes). Defaults to 'SAT' for products
/// created before v0.1.0:48 / migration 0010.
#[serde(default = "default_currency")]
pub price_currency: String,
/// Price in the smallest indivisible unit of `price_currency`:
/// sats for SAT/BTC, cents for USD/EUR.
#[serde(default)]
pub price_value: i64,
pub active: bool,
/// Arbitrary JSON metadata the developer can attach.
pub metadata: serde_json::Value,
pub created_at: String,
pub updated_at: String,
}
fn default_currency() -> String {
"SAT".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InvoiceStatus {
Pending,
Settled,
Expired,
Invalid,
}
impl InvoiceStatus {
pub fn as_str(&self) -> &'static str {
match self {
InvoiceStatus::Pending => "pending",
InvoiceStatus::Settled => "settled",
InvoiceStatus::Expired => "expired",
InvoiceStatus::Invalid => "invalid",
}
}
pub fn parse(s: &str) -> Self {
match s {
"settled" => InvoiceStatus::Settled,
"expired" => InvoiceStatus::Expired,
"invalid" => InvoiceStatus::Invalid,
_ => InvoiceStatus::Pending,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invoice {
pub id: String,
pub btcpay_invoice_id: String,
pub product_id: String,
pub status: String,
pub buyer_email: Option<String>,
pub buyer_note: Option<String>,
pub amount_sats: i64,
pub checkout_url: String,
pub created_at: String,
pub updated_at: String,
/// Policy chosen by the buyer at purchase time. NULL on pre-:27 invoices,
/// in which case `issue_license_for_invoice` falls back to picking the
/// product's default policy. Migration 0007 adds the column.
#[serde(default)]
pub policy_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LicenseStatus {
Active,
Revoked,
/// Temporarily disabled but recoverable — distinct from revocation, which
/// is terminal. Suspended licenses fail `/v1/validate` with reason
/// `suspended` until an admin un-suspends them.
Suspended,
}
impl LicenseStatus {
pub fn as_str(&self) -> &'static str {
match self {
LicenseStatus::Active => "active",
LicenseStatus::Revoked => "revoked",
LicenseStatus::Suspended => "suspended",
}
}
}
/// Full license row. Older fields are unchanged; v2 columns live behind
/// `Option`s since they were introduced in migration 0003.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct License {
pub id: String,
pub product_id: String,
pub invoice_id: Option<String>,
pub status: String,
pub fingerprint: Option<String>,
pub bound_identity: Option<String>,
pub issued_at: String,
pub revoked_at: Option<String>,
pub revocation_reason: Option<String>,
pub metadata: serde_json::Value,
// v2 / migration 0003 fields
pub policy_id: Option<String>,
pub expires_at: Option<String>,
pub grace_seconds: i64,
pub max_machines: i64,
pub suspended_at: Option<String>,
pub suspension_reason: Option<String>,
pub entitlements: Vec<String>,
pub is_trial: bool,
pub nostr_npub: Option<String>,
pub buyer_email: Option<String>,
}
/// Reusable license template. A policy says "when we issue a license under
/// this slug, set these defaults" (duration, grace, entitlements, machine
/// cap, trial flag, price override, optional tip recipient).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Policy {
pub id: String,
pub product_id: String,
pub name: String,
pub slug: String,
pub duration_seconds: i64,
pub grace_seconds: i64,
pub max_machines: i64,
pub is_trial: bool,
pub price_sats_override: Option<i64>,
pub entitlements: Vec<String>,
pub metadata: serde_json::Value,
pub active: bool,
/// Lightning Address (user@domain) the daemon tips a percentage of
/// each successful issuance to. None = no tipping. The amount is
/// `license_price_sats * tip_pct_bps / 10000`. Tip failures never
/// block license issuance.
pub tip_recipient: Option<String>,
/// Percentage in basis points (1bps = 0.01%; 100bps = 1%; 10000bps = 100%).
/// 0 = no tipping. Capped at 10000 server-side.
pub tip_pct_bps: i64,
/// Free-form label for the tip recipient — surfaced in the audit log.
pub tip_label: Option<String>,
/// When true, the policy is rendered on /buy/<product-slug> as a
/// selectable tier card. Operators can mark "Comp / press" or
/// "Internal team seat" policies as private to keep them off the
/// public buy page while still issuing them via admin tooling.
/// Defaults to true; migration 0007 adds this column.
#[serde(default = "default_true")]
pub public: bool,
/// Recurring subscription cadence (migration 0011). When `is_recurring`
/// is true, the renewal worker will create a fresh invoice every
/// `renewal_period_days` and the buy page renders the price as
/// "every N days" / "monthly".
#[serde(default)]
pub is_recurring: bool,
/// Days between renewal cycles. Ignored when `is_recurring = false`.
/// Common values: 30 (monthly), 365 (annual).
#[serde(default)]
pub renewal_period_days: i64,
/// Days the subscription stays in `past_due` before transitioning to
/// `lapsed`. Migration default is 7.
#[serde(default = "default_grace_period_days")]
pub grace_period_days: i64,
/// Free-trial length at first cycle. 0 = no trial. The first invoice
/// is still issued (for $0 / 1-sat) so the buyer email + license
/// flow is consistent; renewal worker charges the real price after
/// `trial_days`.
#[serde(default)]
pub trial_days: i64,
pub created_at: String,
pub updated_at: String,
}
fn default_grace_period_days() -> i64 { 7 }
fn default_true() -> bool { true }
/// A machine activated under a license. One row per active install.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Machine {
pub id: String,
pub license_id: String,
pub fingerprint: String,
pub fingerprint_hash: String,
pub hostname: Option<String>,
pub platform: Option<String>,
pub ip_last_seen: Option<String>,
pub activated_at: String,
pub last_heartbeat_at: Option<String>,
pub deactivated_at: Option<String>,
pub deactivation_reason: Option<String>,
}
impl Machine {
pub fn is_active(&self) -> bool {
self.deactivated_at.is_none()
}
}
/// Outbound webhook subscription.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookEndpoint {
pub id: String,
pub url: String,
/// HMAC-SHA256 secret — never returned on list endpoints after creation.
#[serde(skip_serializing_if = "Option::is_none")]
pub secret: Option<String>,
pub event_types: Vec<String>,
pub active: bool,
pub description: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookDelivery {
pub id: String,
pub endpoint_id: String,
pub event_type: String,
pub payload_json: String,
pub attempt_count: i64,
pub next_attempt_at: Option<String>,
pub last_status_code: Option<i64>,
pub last_error: Option<String>,
pub delivered_at: Option<String>,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEntry {
pub id: i64,
pub actor_kind: String,
pub actor_hash: Option<String>,
pub action: String,
pub target_kind: Option<String>,
pub target_id: Option<String>,
pub request_ip: Option<String>,
pub user_agent: Option<String>,
pub details: serde_json::Value,
pub occurred_at: String,
}
/// Discount / referral code. See `migrations/0004_discount_codes.sql`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscountCode {
pub id: String,
pub code: String,
/// 'percent' | 'fixed_sats'.
pub kind: String,
/// Basis points if `kind == 'percent'` (0..=10000); sats if `kind == 'fixed_sats'`.
pub amount: i64,
pub max_uses: Option<i64>,
pub used_count: i64,
pub expires_at: Option<String>,
pub applies_to_product_id: Option<String>,
pub applies_to_policy_id: Option<String>,
pub referrer_label: Option<String>,
pub description: String,
pub active: bool,
pub created_at: String,
pub updated_at: String,
}
/// One row per (code, invoice) pair. Status transitions:
/// pending → redeemed (invoice settled, license issued)
/// pending → cancelled (invoice expired or invalidated)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscountRedemption {
pub id: String,
pub code_id: String,
pub invoice_id: String,
pub license_id: Option<String>,
/// 'pending' | 'redeemed' | 'cancelled'.
pub status: String,
pub discount_applied_sats: i64,
pub base_price_sats: i64,
pub final_price_sats: i64,
pub created_at: String,
pub updated_at: String,
}