//! 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, pub buyer_note: Option, 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, } #[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, pub status: String, pub fingerprint: Option, pub bound_identity: Option, pub issued_at: String, pub revoked_at: Option, pub revocation_reason: Option, pub metadata: serde_json::Value, // v2 / migration 0003 fields pub policy_id: Option, pub expires_at: Option, pub grace_seconds: i64, pub max_machines: i64, pub suspended_at: Option, pub suspension_reason: Option, pub entitlements: Vec, pub is_trial: bool, pub nostr_npub: Option, pub buyer_email: Option, } /// 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, pub entitlements: Vec, 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, /// 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, /// When true, the policy is rendered on /buy/ 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, pub platform: Option, pub ip_last_seen: Option, pub activated_at: String, pub last_heartbeat_at: Option, pub deactivated_at: Option, pub deactivation_reason: Option, } 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, pub event_types: Vec, 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, pub last_status_code: Option, pub last_error: Option, pub delivered_at: Option, pub created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuditEntry { pub id: i64, pub actor_kind: String, pub actor_hash: Option, pub action: String, pub target_kind: Option, pub target_id: Option, pub request_ip: Option, pub user_agent: Option, 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, pub used_count: i64, pub expires_at: Option, pub applies_to_product_id: Option, pub applies_to_policy_id: Option, pub referrer_label: Option, 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, /// '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, }